codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,817 @@
1
+ """PMA CLI commands for Project Management Assistant."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+ import typer
11
+
12
+ from ...bootstrap import ensure_pma_docs
13
+ from ...core.config import load_hub_config
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ pma_app = typer.Typer(add_completion=False, rich_markup_mode=None)
18
+ docs_app = typer.Typer(add_completion=False, rich_markup_mode=None, name="docs")
19
+ context_app = typer.Typer(add_completion=False, rich_markup_mode=None, name="context")
20
+ pma_app.add_typer(docs_app)
21
+ pma_app.add_typer(context_app)
22
+
23
+
24
+ def _build_pma_url(config, path: str) -> str:
25
+ base_path = config.server_base_path or ""
26
+ if base_path.endswith("/") and path.startswith("/"):
27
+ base_path = base_path[:-1]
28
+ return f"http://{config.server_host}:{config.server_port}{base_path}/hub/pma{path}"
29
+
30
+
31
+ def _resolve_hub_path(path: Optional[Path]) -> Path:
32
+ if path:
33
+ candidate = path
34
+ if candidate.is_dir():
35
+ candidate = candidate / "codex-autorunner.yml"
36
+ if not candidate.exists():
37
+ candidate = path / ".codex-autorunner" / "config.yml"
38
+ if candidate.exists():
39
+ return candidate.parent.parent.resolve()
40
+ return Path.cwd()
41
+
42
+
43
+ def _request_json(
44
+ method: str,
45
+ url: str,
46
+ payload: Optional[dict] = None,
47
+ token_env: Optional[str] = None,
48
+ ) -> dict:
49
+ import os
50
+
51
+ headers = None
52
+ if token_env:
53
+ token = os.environ.get(token_env)
54
+ if token and token.strip():
55
+ headers = {"Authorization": f"Bearer {token.strip()}"}
56
+ response = httpx.request(method, url, json=payload, timeout=30.0, headers=headers)
57
+ response.raise_for_status()
58
+ data = response.json()
59
+ return data if isinstance(data, dict) else {}
60
+
61
+
62
+ def _is_json_response_error(data: dict) -> Optional[str]:
63
+ if not isinstance(data, dict):
64
+ return "Unexpected response format"
65
+ if data.get("detail"):
66
+ return str(data["detail"])
67
+ if data.get("error"):
68
+ return str(data["error"])
69
+ return None
70
+
71
+
72
+ @pma_app.command("chat")
73
+ def pma_chat(
74
+ message: str = typer.Argument(..., help="Message to send to PMA"),
75
+ agent: Optional[str] = typer.Option(
76
+ None, "--agent", help="Agent to use (codex|opencode)"
77
+ ),
78
+ model: Optional[str] = typer.Option(None, "--model", help="Model override"),
79
+ reasoning: Optional[str] = typer.Option(
80
+ None, "--reasoning", help="Reasoning effort override"
81
+ ),
82
+ stream: bool = typer.Option(False, "--stream", help="Stream response tokens"),
83
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
84
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
85
+ ):
86
+ """Send a message to the Project Management Assistant."""
87
+ hub_root = _resolve_hub_path(path)
88
+ try:
89
+ config = load_hub_config(hub_root)
90
+ except Exception as exc:
91
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
92
+ raise typer.Exit(code=1) from None
93
+
94
+ url = _build_pma_url(config, "/chat")
95
+ payload: dict[str, Any] = {"message": message, "stream": stream}
96
+ if agent:
97
+ payload["agent"] = agent
98
+ if model:
99
+ payload["model"] = model
100
+ if reasoning:
101
+ payload["reasoning"] = reasoning
102
+
103
+ if stream:
104
+ import os
105
+
106
+ from ...integrations.app_server.event_buffer import parse_sse_line
107
+
108
+ token_env = config.server_auth_token_env
109
+ headers = None
110
+ if token_env:
111
+ token = os.environ.get(token_env)
112
+ if token and token.strip():
113
+ headers = {"Authorization": f"Bearer {token.strip()}"}
114
+
115
+ try:
116
+ with httpx.stream(
117
+ "POST", url, json=payload, timeout=240.0, headers=headers
118
+ ) as response:
119
+ response.raise_for_status()
120
+ for line in response.iter_lines():
121
+ if not line:
122
+ continue
123
+ event_type, data = parse_sse_line(line)
124
+ if event_type is None or data is None:
125
+ continue
126
+ if event_type == "status":
127
+ if output_json:
128
+ typer.echo(
129
+ json.dumps({"event": "status", **data}, indent=2)
130
+ )
131
+ continue
132
+ if event_type == "token":
133
+ token = data.get("token", "") if isinstance(data, dict) else ""
134
+ if output_json:
135
+ typer.echo(
136
+ json.dumps({"event": "token", "token": token}, indent=2)
137
+ )
138
+ else:
139
+ typer.echo(token, nl=False)
140
+ elif event_type == "update":
141
+ status = data.get("status") if isinstance(data, dict) else ""
142
+ msg = data.get("message") if isinstance(data, dict) else ""
143
+ if output_json:
144
+ typer.echo(
145
+ json.dumps(
146
+ {
147
+ "event": "update",
148
+ "status": status,
149
+ "message": msg,
150
+ },
151
+ indent=2,
152
+ )
153
+ )
154
+ else:
155
+ typer.echo(f"\nStatus: {status}")
156
+ elif event_type == "error":
157
+ detail = (
158
+ data.get("detail")
159
+ if isinstance(data, dict)
160
+ else "Unknown error"
161
+ )
162
+ if output_json:
163
+ typer.echo(
164
+ json.dumps(
165
+ {"event": "error", "detail": detail}, indent=2
166
+ )
167
+ )
168
+ else:
169
+ typer.echo(f"\nError: {detail}", err=True)
170
+ elif event_type == "done":
171
+ if not output_json:
172
+ typer.echo()
173
+ return
174
+ elif event_type == "interrupted":
175
+ detail = (
176
+ data.get("detail")
177
+ if isinstance(data, dict)
178
+ else "Interrupted"
179
+ )
180
+ if output_json:
181
+ typer.echo(
182
+ json.dumps(
183
+ {"event": "interrupted", "detail": detail}, indent=2
184
+ )
185
+ )
186
+ else:
187
+ typer.echo(f"\nInterrupted: {detail}")
188
+ return
189
+ except httpx.HTTPError as exc:
190
+ typer.echo(f"HTTP error: {exc}", err=True)
191
+ raise typer.Exit(code=1) from None
192
+ except Exception as exc:
193
+ typer.echo(f"Error: {exc}", err=True)
194
+ raise typer.Exit(code=1) from None
195
+ return
196
+
197
+ try:
198
+ data = _request_json(
199
+ "POST", url, payload, token_env=config.server_auth_token_env
200
+ )
201
+ except httpx.HTTPError as exc:
202
+ typer.echo(f"HTTP error: {exc}", err=True)
203
+ raise typer.Exit(code=1) from None
204
+ except Exception as exc:
205
+ typer.echo(f"Error: {exc}", err=True)
206
+ raise typer.Exit(code=1) from None
207
+
208
+ error = _is_json_response_error(data)
209
+ if error:
210
+ if output_json:
211
+ typer.echo(json.dumps({"error": error, "detail": data}, indent=2))
212
+ else:
213
+ typer.echo(f"Chat failed: {error}", err=True)
214
+ raise typer.Exit(code=1) from None
215
+
216
+ if output_json:
217
+ typer.echo(json.dumps(data, indent=2))
218
+ else:
219
+ msg = data.get("message") if isinstance(data, dict) else ""
220
+ typer.echo(msg or "No message returned")
221
+
222
+
223
+ @pma_app.command("interrupt")
224
+ def pma_interrupt(
225
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
226
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
227
+ ):
228
+ """Interrupt a running PMA chat."""
229
+ hub_root = _resolve_hub_path(path)
230
+ try:
231
+ config = load_hub_config(hub_root)
232
+ except Exception as exc:
233
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
234
+ raise typer.Exit(code=1) from None
235
+
236
+ url = _build_pma_url(config, "/interrupt")
237
+
238
+ try:
239
+ data = _request_json("POST", url, token_env=config.server_auth_token_env)
240
+ except httpx.HTTPError as exc:
241
+ typer.echo(f"HTTP error: {exc}", err=True)
242
+ raise typer.Exit(code=1) from None
243
+ except Exception as exc:
244
+ typer.echo(f"Error: {exc}", err=True)
245
+ raise typer.Exit(code=1) from None
246
+
247
+ if output_json:
248
+ typer.echo(json.dumps(data, indent=2))
249
+ else:
250
+ interrupted = data.get("interrupted") if isinstance(data, dict) else False
251
+ detail = data.get("detail") if isinstance(data, dict) else ""
252
+ agent = data.get("agent") if isinstance(data, dict) else ""
253
+ if interrupted:
254
+ typer.echo(f"PMA chat interrupted (agent={agent})")
255
+ else:
256
+ typer.echo("No active PMA chat to interrupt")
257
+ if detail:
258
+ typer.echo(f"Detail: {detail}")
259
+
260
+
261
+ @pma_app.command("reset")
262
+ def pma_reset(
263
+ agent: Optional[str] = typer.Option(
264
+ None, "--agent", help="Agent thread to reset (opencode|codex|all)"
265
+ ),
266
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
267
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
268
+ ):
269
+ """Reset PMA thread state."""
270
+ hub_root = _resolve_hub_path(path)
271
+ try:
272
+ config = load_hub_config(hub_root)
273
+ except Exception as exc:
274
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
275
+ raise typer.Exit(code=1) from None
276
+
277
+ url = _build_pma_url(config, "/thread/reset")
278
+ payload: dict[str, Any] = {}
279
+ if agent:
280
+ payload["agent"] = agent
281
+
282
+ try:
283
+ data = _request_json(
284
+ "POST", url, payload, token_env=config.server_auth_token_env
285
+ )
286
+ except httpx.HTTPError as exc:
287
+ typer.echo(f"HTTP error: {exc}", err=True)
288
+ raise typer.Exit(code=1) from None
289
+ except Exception as exc:
290
+ typer.echo(f"Error: {exc}", err=True)
291
+ raise typer.Exit(code=1) from None
292
+
293
+ if output_json:
294
+ typer.echo(json.dumps(data, indent=2))
295
+ else:
296
+ cleared = data.get("cleared") if isinstance(data, dict) else []
297
+ if cleared:
298
+ typer.echo(f"Cleared threads: {', '.join(cleared)}")
299
+ else:
300
+ typer.echo("No threads to clear")
301
+
302
+
303
+ @pma_app.command("active")
304
+ def pma_active(
305
+ client_turn_id: Optional[str] = typer.Option(
306
+ None, "--turn-id", help="Filter by client turn ID"
307
+ ),
308
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
309
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
310
+ ):
311
+ """Show active PMA chat status."""
312
+ hub_root = _resolve_hub_path(path)
313
+ try:
314
+ config = load_hub_config(hub_root)
315
+ except Exception as exc:
316
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
317
+ raise typer.Exit(code=1) from None
318
+
319
+ url = _build_pma_url(config, "/active")
320
+ params = {}
321
+ if client_turn_id:
322
+ params["client_turn_id"] = client_turn_id
323
+
324
+ try:
325
+ response = httpx.get(url, params=params, timeout=5.0)
326
+ response.raise_for_status()
327
+ data = response.json()
328
+ except httpx.HTTPError as exc:
329
+ typer.echo(f"HTTP error: {exc}", err=True)
330
+ raise typer.Exit(code=1) from None
331
+ except Exception as exc:
332
+ typer.echo(f"Error: {exc}", err=True)
333
+ raise typer.Exit(code=1) from None
334
+
335
+ if output_json:
336
+ typer.echo(json.dumps(data, indent=2))
337
+ else:
338
+ active = data.get("active") if isinstance(data, dict) else False
339
+ current = data.get("current") if isinstance(data, dict) else {}
340
+ last_result = data.get("last_result") if isinstance(data, dict) else {}
341
+
342
+ typer.echo(f"Active: {active}")
343
+ if current:
344
+ status = current.get("status", "unknown")
345
+ agent = current.get("agent", "unknown")
346
+ started = current.get("started_at", "")
347
+ typer.echo(
348
+ f"Current turn: status={status}, agent={agent}, started={started}"
349
+ )
350
+ if last_result:
351
+ status = last_result.get("status", "unknown")
352
+ agent = last_result.get("agent", "unknown")
353
+ finished = last_result.get("finished_at", "")
354
+ typer.echo(
355
+ f"Last result: status={status}, agent={agent}, finished={finished}"
356
+ )
357
+
358
+
359
+ @pma_app.command("agents")
360
+ def pma_agents(
361
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
362
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
363
+ ):
364
+ """List available PMA agents."""
365
+ hub_root = _resolve_hub_path(path)
366
+ try:
367
+ config = load_hub_config(hub_root)
368
+ except Exception as exc:
369
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
370
+ raise typer.Exit(code=1) from None
371
+
372
+ url = _build_pma_url(config, "/agents")
373
+
374
+ try:
375
+ data = _request_json("GET", url, token_env=config.server_auth_token_env)
376
+ except httpx.HTTPError as exc:
377
+ typer.echo(f"HTTP error: {exc}", err=True)
378
+ raise typer.Exit(code=1) from None
379
+ except Exception as exc:
380
+ typer.echo(f"Error: {exc}", err=True)
381
+ raise typer.Exit(code=1) from None
382
+
383
+ if output_json:
384
+ typer.echo(json.dumps(data, indent=2))
385
+ else:
386
+ agents = data.get("agents", []) if isinstance(data, dict) else []
387
+ default = data.get("default", "") if isinstance(data, dict) else ""
388
+ defaults = data.get("defaults", {}) if isinstance(data, dict) else {}
389
+
390
+ typer.echo(f"Default agent: {default or 'none'}")
391
+ if defaults:
392
+ typer.echo("Defaults:")
393
+ for key, value in defaults.items():
394
+ typer.echo(f" {key}: {value}")
395
+ typer.echo(f"\nAgents ({len(agents)}):")
396
+ for agent in agents:
397
+ if not isinstance(agent, dict):
398
+ continue
399
+ agent_id = agent.get("id", "")
400
+ agent_name = agent.get("name", agent_id)
401
+ available = agent.get("available", False)
402
+ status = "available" if available else "unavailable"
403
+ typer.echo(f" - {agent_name} ({agent_id}): {status}")
404
+
405
+
406
+ @pma_app.command("models")
407
+ def pma_models(
408
+ agent: str = typer.Argument(..., help="Agent ID (codex|opencode)"),
409
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
410
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
411
+ ):
412
+ """List available models for an agent."""
413
+ hub_root = _resolve_hub_path(path)
414
+ try:
415
+ config = load_hub_config(hub_root)
416
+ except Exception as exc:
417
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
418
+ raise typer.Exit(code=1) from None
419
+
420
+ url = _build_pma_url(config, f"/agents/{agent}/models")
421
+
422
+ try:
423
+ data = _request_json("GET", url, token_env=config.server_auth_token_env)
424
+ except httpx.HTTPError as exc:
425
+ typer.echo(f"HTTP error: {exc}", err=True)
426
+ raise typer.Exit(code=1) from None
427
+ except Exception as exc:
428
+ typer.echo(f"Error: {exc}", err=True)
429
+ raise typer.Exit(code=1) from None
430
+
431
+ if output_json:
432
+ typer.echo(json.dumps(data, indent=2))
433
+ else:
434
+ models = data.get("models", []) if isinstance(data, dict) else []
435
+ default_model = data.get("default_model", "") if isinstance(data, dict) else ""
436
+
437
+ typer.echo(f"Default model: {default_model or 'none'}")
438
+ typer.echo(f"\nModels ({len(models)}):")
439
+ for model in models:
440
+ if not isinstance(model, dict):
441
+ continue
442
+ model_id = model.get("id", "")
443
+ model_name = model.get("name", model_id)
444
+ typer.echo(f" - {model_name} ({model_id})")
445
+
446
+
447
+ @pma_app.command("files")
448
+ def pma_files(
449
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
450
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
451
+ ):
452
+ """List files in PMA inbox and outbox."""
453
+ hub_root = _resolve_hub_path(path)
454
+ try:
455
+ config = load_hub_config(hub_root)
456
+ except Exception as exc:
457
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
458
+ raise typer.Exit(code=1) from None
459
+
460
+ url = _build_pma_url(config, "/files")
461
+
462
+ try:
463
+ data = _request_json("GET", url, token_env=config.server_auth_token_env)
464
+ except httpx.HTTPError as exc:
465
+ typer.echo(f"HTTP error: {exc}", err=True)
466
+ raise typer.Exit(code=1) from None
467
+ except Exception as exc:
468
+ typer.echo(f"Error: {exc}", err=True)
469
+ raise typer.Exit(code=1) from None
470
+
471
+ if output_json:
472
+ typer.echo(json.dumps(data, indent=2))
473
+ else:
474
+ inbox = data.get("inbox", []) if isinstance(data, dict) else []
475
+ outbox = data.get("outbox", []) if isinstance(data, dict) else []
476
+
477
+ typer.echo(f"Inbox ({len(inbox)}):")
478
+ for file in inbox:
479
+ if not isinstance(file, dict):
480
+ continue
481
+ name = file.get("name", "")
482
+ size = file.get("size", 0)
483
+ modified = file.get("modified_at", "")
484
+ typer.echo(f" - {name} ({size} bytes, {modified})")
485
+
486
+ typer.echo(f"\nOutbox ({len(outbox)}):")
487
+ for file in outbox:
488
+ if not isinstance(file, dict):
489
+ continue
490
+ name = file.get("name", "")
491
+ size = file.get("size", 0)
492
+ modified = file.get("modified_at", "")
493
+ typer.echo(f" - {name} ({size} bytes, {modified})")
494
+
495
+
496
+ @pma_app.command("upload")
497
+ def pma_upload(
498
+ box: str = typer.Argument(..., help="Target box (inbox|outbox)"),
499
+ files: list[Path] = typer.Argument(..., help="Files to upload"),
500
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
501
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
502
+ ):
503
+ """Upload files to PMA inbox or outbox."""
504
+ hub_root = _resolve_hub_path(path)
505
+ try:
506
+ config = load_hub_config(hub_root)
507
+ except Exception as exc:
508
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
509
+ raise typer.Exit(code=1) from None
510
+
511
+ if box not in ("inbox", "outbox"):
512
+ typer.echo("Box must be 'inbox' or 'outbox'", err=True)
513
+ raise typer.Exit(code=1) from None
514
+
515
+ url = _build_pma_url(config, f"/files/{box}")
516
+
517
+ for file_path in files:
518
+ if not file_path.exists():
519
+ typer.echo(f"File not found: {file_path}", err=True)
520
+ raise typer.Exit(code=1) from None
521
+
522
+ import os
523
+
524
+ token_env = config.server_auth_token_env
525
+ headers = {}
526
+ if token_env:
527
+ token = os.environ.get(token_env)
528
+ if token and token.strip():
529
+ headers["Authorization"] = f"Bearer {token.strip()}"
530
+
531
+ saved_files = []
532
+ for file_path in files:
533
+ try:
534
+ with open(file_path, "rb") as f:
535
+ files_data = {"file": (file_path.name, f, "application/octet-stream")}
536
+ response = httpx.post(
537
+ url, files=files_data, headers=headers, timeout=30.0
538
+ )
539
+ response.raise_for_status()
540
+ data = response.json()
541
+ saved = data.get("saved", []) if isinstance(data, dict) else []
542
+ saved_files.extend(saved)
543
+ except httpx.HTTPError as exc:
544
+ typer.echo(f"HTTP error uploading {file_path}: {exc}", err=True)
545
+ raise typer.Exit(code=1) from None
546
+ except OSError as exc:
547
+ typer.echo(f"Error reading file {file_path}: {exc}", err=True)
548
+ raise typer.Exit(code=1) from None
549
+
550
+ if output_json:
551
+ typer.echo(json.dumps({"saved": saved_files}, indent=2))
552
+ else:
553
+ typer.echo(f"Uploaded {len(saved_files)} file(s): {', '.join(saved_files)}")
554
+
555
+
556
+ @pma_app.command("download")
557
+ def pma_download(
558
+ box: str = typer.Argument(..., help="Source box (inbox|outbox)"),
559
+ filename: str = typer.Argument(..., help="File to download"),
560
+ output: Optional[Path] = typer.Option(
561
+ None, "--output", "-o", help="Output path (default: current directory)"
562
+ ),
563
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
564
+ ):
565
+ """Download a file from PMA inbox or outbox."""
566
+ hub_root = _resolve_hub_path(path)
567
+ try:
568
+ config = load_hub_config(hub_root)
569
+ except Exception as exc:
570
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
571
+ raise typer.Exit(code=1) from None
572
+
573
+ if box not in ("inbox", "outbox"):
574
+ typer.echo("Box must be 'inbox' or 'outbox'", err=True)
575
+ raise typer.Exit(code=1) from None
576
+
577
+ url = _build_pma_url(config, f"/files/{box}/{filename}")
578
+
579
+ try:
580
+ response = httpx.get(url, timeout=30.0)
581
+ response.raise_for_status()
582
+ except httpx.HTTPError as exc:
583
+ typer.echo(f"HTTP error: {exc}", err=True)
584
+ raise typer.Exit(code=1) from None
585
+
586
+ output_path = output if output else Path(filename)
587
+ output_path.write_bytes(response.content)
588
+ typer.echo(f"Downloaded to {output_path}")
589
+
590
+
591
+ @pma_app.command("delete")
592
+ def pma_delete(
593
+ box: Optional[str] = typer.Argument(None, help="Target box (inbox|outbox)"),
594
+ filename: Optional[str] = typer.Argument(None, help="File to delete"),
595
+ all_files: bool = typer.Option(False, "--all", help="Delete all files in the box"),
596
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
597
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
598
+ ):
599
+ """Delete files from PMA inbox or outbox."""
600
+ hub_root = _resolve_hub_path(path)
601
+ try:
602
+ config = load_hub_config(hub_root)
603
+ except Exception as exc:
604
+ typer.echo(f"Failed to load hub config: {exc}", err=True)
605
+ raise typer.Exit(code=1) from None
606
+
607
+ if all_files:
608
+ if not box or box not in ("inbox", "outbox"):
609
+ typer.echo("Box must be 'inbox' or 'outbox' when using --all", err=True)
610
+ raise typer.Exit(code=1) from None
611
+ url = _build_pma_url(config, f"/files/{box}")
612
+ method = "DELETE"
613
+ payload = None
614
+ else:
615
+ if not box or not filename:
616
+ typer.echo("Box and filename are required (or use --all)", err=True)
617
+ raise typer.Exit(code=1) from None
618
+ if box not in ("inbox", "outbox"):
619
+ typer.echo("Box must be 'inbox' or 'outbox'", err=True)
620
+ raise typer.Exit(code=1) from None
621
+ url = _build_pma_url(config, f"/files/{box}/{filename}")
622
+ method = "DELETE"
623
+ payload = None
624
+
625
+ try:
626
+ response = httpx.request(method, url, json=payload, timeout=30.0)
627
+ response.raise_for_status()
628
+ data = response.json()
629
+ except httpx.HTTPError as exc:
630
+ typer.echo(f"HTTP error: {exc}", err=True)
631
+ raise typer.Exit(code=1) from None
632
+ except Exception as exc:
633
+ typer.echo(f"Error: {exc}", err=True)
634
+ raise typer.Exit(code=1) from None
635
+
636
+ if output_json:
637
+ typer.echo(json.dumps(data, indent=2))
638
+ else:
639
+ if all_files:
640
+ typer.echo(f"Deleted all files in {box}")
641
+ else:
642
+ typer.echo(f"Deleted {filename} from {box}")
643
+
644
+
645
+ @docs_app.command("show")
646
+ def pma_docs_show(
647
+ doc_type: str = typer.Argument(..., help="Document type: agents, active, or log"),
648
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
649
+ ):
650
+ """Show PMA docs content to stdout."""
651
+ hub_root = _resolve_hub_path(path)
652
+ try:
653
+ ensure_pma_docs(hub_root)
654
+ except Exception as exc:
655
+ typer.echo(f"Failed to ensure PMA docs: {exc}", err=True)
656
+ raise typer.Exit(code=1) from None
657
+
658
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
659
+
660
+ if doc_type == "agents":
661
+ doc_path = pma_dir / "AGENTS.md"
662
+ elif doc_type == "active":
663
+ doc_path = pma_dir / "active_context.md"
664
+ elif doc_type == "log":
665
+ doc_path = pma_dir / "context_log.md"
666
+ else:
667
+ typer.echo("Invalid doc_type. Must be one of: agents, active, log", err=True)
668
+ raise typer.Exit(code=1) from None
669
+
670
+ try:
671
+ content = doc_path.read_text(encoding="utf-8")
672
+ typer.echo(content, nl=False)
673
+ except OSError as exc:
674
+ typer.echo(f"Failed to read {doc_path}: {exc}", err=True)
675
+ raise typer.Exit(code=1) from None
676
+
677
+
678
+ @context_app.command("reset")
679
+ def pma_context_reset(
680
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
681
+ ):
682
+ """Reset active_context.md to a minimal header."""
683
+ hub_root = _resolve_hub_path(path)
684
+ try:
685
+ ensure_pma_docs(hub_root)
686
+ except Exception as exc:
687
+ typer.echo(f"Failed to ensure PMA docs: {exc}", err=True)
688
+ raise typer.Exit(code=1) from None
689
+
690
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
691
+ active_context_path = pma_dir / "active_context.md"
692
+
693
+ minimal_content = """# PMA active context (short-lived)
694
+
695
+ Use this file for the current working set: active projects, open questions, links, and immediate next steps.
696
+
697
+ Pruning guidance:
698
+ - Keep this file compact (prefer bullet points).
699
+ - When it grows too large, summarize older items and move durable guidance to `AGENTS.md`.
700
+ - Before a major prune, append a timestamped snapshot to `context_log.md`.
701
+ """
702
+
703
+ try:
704
+ active_context_path.write_text(minimal_content, encoding="utf-8")
705
+ typer.echo(f"Reset active_context.md at {active_context_path}")
706
+ except OSError as exc:
707
+ typer.echo(f"Failed to write {active_context_path}: {exc}", err=True)
708
+ raise typer.Exit(code=1) from None
709
+
710
+
711
+ @context_app.command("snapshot")
712
+ def pma_context_snapshot(
713
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
714
+ ):
715
+ """Snapshot active_context.md into context_log.md with ISO timestamp."""
716
+ hub_root = _resolve_hub_path(path)
717
+ try:
718
+ ensure_pma_docs(hub_root)
719
+ except Exception as exc:
720
+ typer.echo(f"Failed to ensure PMA docs: {exc}", err=True)
721
+ raise typer.Exit(code=1) from None
722
+
723
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
724
+ active_context_path = pma_dir / "active_context.md"
725
+ context_log_path = pma_dir / "context_log.md"
726
+
727
+ try:
728
+ active_content = active_context_path.read_text(encoding="utf-8")
729
+ except OSError as exc:
730
+ typer.echo(f"Failed to read {active_context_path}: {exc}", err=True)
731
+ raise typer.Exit(code=1) from None
732
+
733
+ timestamp = datetime.now(timezone.utc).isoformat()
734
+ snapshot_header = f"\n\n## Snapshot: {timestamp}\n\n"
735
+ snapshot_content = snapshot_header + active_content
736
+
737
+ try:
738
+ with context_log_path.open("a", encoding="utf-8") as f:
739
+ f.write(snapshot_content)
740
+ typer.echo(f"Appended snapshot to {context_log_path}")
741
+ except OSError as exc:
742
+ typer.echo(f"Failed to write {context_log_path}: {exc}", err=True)
743
+ raise typer.Exit(code=1) from None
744
+
745
+
746
+ @context_app.command("prune")
747
+ def pma_context_prune(
748
+ path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
749
+ ):
750
+ """Prune active_context.md if over budget (snapshot first)."""
751
+ hub_root = _resolve_hub_path(path)
752
+
753
+ max_lines = 200
754
+ try:
755
+ config = load_hub_config(hub_root)
756
+ pma_cfg = getattr(config, "pma", None)
757
+ if pma_cfg is not None:
758
+ max_lines = int(getattr(pma_cfg, "active_context_max_lines", max_lines))
759
+ except Exception:
760
+ pass
761
+
762
+ try:
763
+ ensure_pma_docs(hub_root)
764
+ except Exception as exc:
765
+ typer.echo(f"Failed to ensure PMA docs: {exc}", err=True)
766
+ raise typer.Exit(code=1) from None
767
+
768
+ pma_dir = hub_root / ".codex-autorunner" / "pma"
769
+ active_context_path = pma_dir / "active_context.md"
770
+
771
+ try:
772
+ active_content = active_context_path.read_text(encoding="utf-8")
773
+ line_count = len(active_content.splitlines())
774
+ except OSError as exc:
775
+ typer.echo(f"Failed to read {active_context_path}: {exc}", err=True)
776
+ raise typer.Exit(code=1) from None
777
+
778
+ if line_count <= max_lines:
779
+ typer.echo(
780
+ f"active_context.md has {line_count} lines (budget: {max_lines}), no prune needed"
781
+ )
782
+ return
783
+
784
+ typer.echo(
785
+ f"active_context.md has {line_count} lines (budget: {max_lines}), snapshotting and pruning"
786
+ )
787
+
788
+ timestamp = datetime.now(timezone.utc).isoformat()
789
+ snapshot_header = f"\n\n## Snapshot: {timestamp}\n\n"
790
+ snapshot_content = snapshot_header + active_content
791
+
792
+ context_log_path = pma_dir / "context_log.md"
793
+ try:
794
+ with context_log_path.open("a", encoding="utf-8") as f:
795
+ f.write(snapshot_content)
796
+ except OSError as exc:
797
+ typer.echo(f"Failed to write {context_log_path}: {exc}", err=True)
798
+ raise typer.Exit(code=1) from None
799
+
800
+ minimal_content = f"""# PMA active context (short-lived)
801
+
802
+ Use this file for the current working set: active projects, open questions, links, and immediate next steps.
803
+
804
+ Pruning guidance:
805
+ - Keep this file compact (prefer bullet points).
806
+ - When it grows too large, summarize older items and move durable guidance to `AGENTS.md`.
807
+ - Before a major prune, append a timestamped snapshot to `context_log.md`.
808
+
809
+ > Note: This file was pruned on {timestamp} (had {line_count} lines, budget: {max_lines})
810
+ """
811
+
812
+ try:
813
+ active_context_path.write_text(minimal_content, encoding="utf-8")
814
+ typer.echo(f"Pruned active_context.md at {active_context_path}")
815
+ except OSError as exc:
816
+ typer.echo(f"Failed to write {active_context_path}: {exc}", err=True)
817
+ raise typer.Exit(code=1) from None