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
@@ -1,84 +1,20 @@
1
- import subprocess
2
- from functools import lru_cache
3
- from typing import Iterable, Optional
4
-
5
- SUBCOMMAND_HINTS = ("exec", "resume")
6
-
7
-
8
- def extract_flag_value(args: Iterable[str], flag: str) -> Optional[str]:
9
- if not args:
10
- return None
11
- for arg in args:
12
- if not isinstance(arg, str):
13
- continue
14
- if arg.startswith(f"{flag}="):
15
- return arg.split("=", 1)[1] or None
16
- args_list = [str(a) for a in args]
17
- for idx, arg in enumerate(args_list):
18
- if arg == flag and idx + 1 < len(args_list):
19
- return args_list[idx + 1]
20
- return None
21
-
22
-
23
- def inject_flag(
24
- args: Iterable[str],
25
- flag: str,
26
- value: Optional[str],
27
- *,
28
- subcommands: Iterable[str] = SUBCOMMAND_HINTS,
29
- ) -> list[str]:
30
- if not value:
31
- return [str(a) for a in args]
32
- args_list = [str(a) for a in args]
33
- if extract_flag_value(args_list, flag):
34
- return args_list
35
- insert_at = None
36
- for cmd in subcommands:
37
- try:
38
- insert_at = args_list.index(cmd)
39
- break
40
- except ValueError:
41
- continue
42
- if insert_at is None:
43
- # `args` is sometimes a full argv that includes the binary at index 0,
44
- # e.g. ["codex", "--yolo", ...]. In that case, never prepend flags before
45
- # the binary or we'll turn argv[0] into e.g. "--model" and crash at spawn.
46
- if args_list and not args_list[0].startswith("-"):
47
- return [args_list[0], flag, value] + args_list[1:]
48
- return [flag, value] + args_list
49
- return args_list[:insert_at] + [flag, value] + args_list[insert_at:]
50
-
51
-
52
- def apply_codex_options(
53
- args: Iterable[str],
54
- *,
55
- model: Optional[str] = None,
56
- reasoning: Optional[str] = None,
57
- supports_reasoning: Optional[bool] = None,
58
- ) -> list[str]:
59
- with_model = inject_flag(args, "--model", model)
60
- if reasoning and supports_reasoning is False:
61
- return with_model
62
- return inject_flag(with_model, "--reasoning", reasoning)
63
-
64
-
65
- def _read_help_text(binary: str) -> str:
66
- try:
67
- result = subprocess.run(
68
- [binary, "--help"],
69
- capture_output=True,
70
- text=True,
71
- check=False,
72
- )
73
- except FileNotFoundError:
74
- return ""
75
- return "\n".join(filter(None, [result.stdout, result.stderr]))
76
-
77
-
78
- @lru_cache(maxsize=8)
79
- def supports_flag(binary: str, flag: str) -> bool:
80
- return flag in _read_help_text(binary)
81
-
82
-
83
- def supports_reasoning(binary: str) -> bool:
84
- return supports_flag(binary, "--reasoning")
1
+ """Backward-compatible Codex CLI helpers.
2
+
3
+ Delegates to core.utils to avoid duplicated logic.
4
+ """
5
+
6
+ from .core.utils import ( # noqa: F401
7
+ apply_codex_options,
8
+ extract_flag_value,
9
+ inject_flag,
10
+ supports_flag,
11
+ supports_reasoning,
12
+ )
13
+
14
+ __all__ = [
15
+ "apply_codex_options",
16
+ "extract_flag_value",
17
+ "inject_flag",
18
+ "supports_flag",
19
+ "supports_reasoning",
20
+ ]
@@ -1 +1,21 @@
1
1
  """Core runtime primitives."""
2
+
3
+ from .archive import ArchiveResult, archive_worktree_snapshot
4
+ from .context_awareness import CAR_AWARENESS_BLOCK, format_file_role_addendum
5
+ from .lifecycle_events import (
6
+ LifecycleEvent,
7
+ LifecycleEventEmitter,
8
+ LifecycleEventStore,
9
+ LifecycleEventType,
10
+ )
11
+
12
+ __all__ = [
13
+ "ArchiveResult",
14
+ "archive_worktree_snapshot",
15
+ "CAR_AWARENESS_BLOCK",
16
+ "format_file_role_addendum",
17
+ "LifecycleEvent",
18
+ "LifecycleEventEmitter",
19
+ "LifecycleEventStore",
20
+ "LifecycleEventType",
21
+ ]
@@ -13,9 +13,19 @@ from .config import (
13
13
 
14
14
  ABOUT_CAR_BASENAME = "ABOUT_CAR.md"
15
15
  ABOUT_CAR_REL_PATH = Path(".codex-autorunner") / ABOUT_CAR_BASENAME
16
+ TICKET_FLOW_QUICKSTART_BASENAME = "TICKET_FLOW_QUICKSTART.md"
17
+ TICKET_FLOW_QUICKSTART_REL_PATH = (
18
+ Path(".codex-autorunner") / TICKET_FLOW_QUICKSTART_BASENAME
19
+ )
20
+ TICKETS_AGENTS_BASENAME = "AGENTS.md"
21
+ TICKETS_AGENTS_REL_PATH = (
22
+ Path(".codex-autorunner") / "tickets" / TICKETS_AGENTS_BASENAME
23
+ )
16
24
 
17
25
  # If this marker is present, codex-autorunner may safely refresh the file content.
18
26
  ABOUT_CAR_GENERATED_MARKER = "<!-- CAR:AUTOGENERATED -->"
27
+ TICKET_FLOW_QUICKSTART_GENERATED_MARKER = "<!-- CAR:TICKET_FLOW_QUICKSTART -->"
28
+ TICKETS_AGENTS_GENERATED_MARKER = "<!-- CAR:TICKETS_AGENTS -->"
19
29
 
20
30
  CAR_CONTEXT_KEYWORDS = (
21
31
  "car",
@@ -76,7 +86,11 @@ def build_about_car_markdown(
76
86
  "You are running inside **Codex Autorunner (CAR)**.\n\n"
77
87
  "CAR uses a ticket-first workflow.\n\n"
78
88
  "## Required for operation\n"
79
- "- Tickets live under `.codex-autorunner/tickets/`.\n\n"
89
+ "- Tickets live under `.codex-autorunner/tickets/` (per-repo/worktree).\n"
90
+ "- If the user provides ticket files, place them in the repo's `.codex-autorunner/tickets/` folder.\n"
91
+ "- Lint ticket frontmatter after edits (runs against all tickets): `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
92
+ "## Ticket flow quickstart\n"
93
+ f"- Read `{_display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)}` for CLI entrypoints + gotchas.\n\n"
80
94
  "## Optional workspace docs\n"
81
95
  "- **Active context**: "
82
96
  f"`{active_context_disp}`\n"
@@ -91,6 +105,16 @@ def build_about_car_markdown(
91
105
  "- **Dispatch**: An update or message from the agent.\n"
92
106
  "- **Handoff**: Passing control from agent to user (or vice versa).\n"
93
107
  "- **Inbox**: Where the agent receives files/messages.\n\n"
108
+ "## Ticket helpers\n"
109
+ "- Use `.codex-autorunner/bin/ticket_tool.py` to list/create/insert/move tickets; it is portable and venv-free.\n"
110
+ '- Common workflows: insert gap before N (`python3 .codex-autorunner/bin/ticket_tool.py insert --before N`); move a block (`... move --start A --end B --to T`); create with auto-quoted frontmatter (`... create --title "Fix #123" --agent codex`).\n'
111
+ "- After any ticket edits, lint all tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
112
+ "## Ticket templates (optional)\n"
113
+ "- CAR can fetch ticket templates from configured git repos (treat templates as code).\n"
114
+ "- Fetch (prints template to stdout): `car templates fetch <repo_id>:<path>[@<ref>]`\n"
115
+ "- Pin to a commit for determinism: `...@<commit_sha>`\n"
116
+ "- Trusted repos skip scanning. Untrusted repos are scanned (cached by blob SHA) before content is returned.\n"
117
+ "- If fetch or scan fails, pause and notify the user rather than guessing.\n\n"
94
118
  "## How CAR works (short)\n"
95
119
  "- The web UI provides ticket editing + unified file chat.\n"
96
120
  "- `car serve` starts the hub web UI. The **Terminal** tab launches the configured `codex` binary in a PTY.\n"
@@ -139,6 +163,54 @@ def ensure_about_car_file_for_repo(
139
163
  return path
140
164
 
141
165
 
166
+ def build_ticket_flow_quickstart_markdown(*, repo_root: Path) -> str:
167
+ ticket_dir = ".codex-autorunner/tickets/"
168
+ return (
169
+ f"{TICKET_FLOW_QUICKSTART_GENERATED_MARKER}\n"
170
+ "# Ticket Flow Quickstart\n\n"
171
+ "## Start ticket flow via CLI\n"
172
+ "- Bootstrap the first run (creates TICKET-001 if needed):\n"
173
+ " `car flow ticket_flow bootstrap --repo <path>`\n"
174
+ " (alias: `car ticket-flow bootstrap --repo <path>`)\n"
175
+ "- Start/resume without seeding tickets:\n"
176
+ " `car flow ticket_flow start --repo <path>`\n"
177
+ "- Check status:\n"
178
+ " `car flow ticket_flow status --repo <path> [--run-id <uuid>]`\n"
179
+ "- Resume/stop:\n"
180
+ " `car flow ticket_flow resume --repo <path> [--run-id <uuid>]`\n"
181
+ " `car flow ticket_flow stop --repo <path> [--run-id <uuid>]`\n\n"
182
+ "## Where tickets live\n"
183
+ f"- Tickets are per-repo/worktree under `{ticket_dir}`.\n"
184
+ "- If the user provides ticket files, save them directly into that folder.\n\n"
185
+ "## Common gotchas\n"
186
+ "- Hub vs repo: ticket flows run per-repo; CLI commands need a repo path.\n"
187
+ "- `--repo` expects a filesystem path, not a hub repo_id.\n"
188
+ "- Each worktree has its own `.codex-autorunner/` directory.\n"
189
+ "- If this repo/worktree lives under a hub, it must be registered in the hub manifest to show up in the hub UI. Run: `car hub scan` (or create it via `car hub worktree create`).\n"
190
+ )
191
+
192
+
193
+ def build_tickets_agents_markdown(*, repo_root: Path) -> str:
194
+ quickstart_path = _display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)
195
+ return (
196
+ f"{TICKETS_AGENTS_GENERATED_MARKER}\n"
197
+ "# Tickets — AGENTS\n\n"
198
+ "This folder is the authoritative ticket queue for this repo/worktree.\n\n"
199
+ "## Ticket files\n"
200
+ "- Store work items as `TICKET-###*.md` (ordered by number).\n"
201
+ "- Keep frontmatter `done: true|false` in sync with completion.\n"
202
+ "- After edits, lint tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
203
+ "## Ticket CLI (portable)\n"
204
+ "- List: `python3 .codex-autorunner/bin/ticket_tool.py list`\n"
205
+ '- Create: `python3 .codex-autorunner/bin/ticket_tool.py create --title "..." --agent codex`\n'
206
+ "- Insert gap: `python3 .codex-autorunner/bin/ticket_tool.py insert --before N`\n"
207
+ "- Move block: `python3 .codex-autorunner/bin/ticket_tool.py move --start A --end B --to T`\n"
208
+ "- Lint: `python3 .codex-autorunner/bin/ticket_tool.py lint`\n\n"
209
+ "## Ticket flow (runner)\n"
210
+ f"- See `{quickstart_path}` for `car flow ticket_flow ...` commands.\n"
211
+ )
212
+
213
+
142
214
  def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
143
215
  """Config-aware wrapper that uses configured doc paths."""
144
216
  repo_root = config.root
@@ -148,3 +220,49 @@ def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
148
220
  "spec": config.doc_path("spec"),
149
221
  }
150
222
  return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
223
+
224
+
225
+ def ensure_ticket_flow_quickstart_file_for_repo(
226
+ repo_root: Path, *, force: bool = False
227
+ ) -> Path:
228
+ path = repo_root / TICKET_FLOW_QUICKSTART_REL_PATH
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ content = build_ticket_flow_quickstart_markdown(repo_root=repo_root)
231
+ if content and not content.endswith("\n"):
232
+ content += "\n"
233
+
234
+ if path.exists() and not force:
235
+ try:
236
+ existing = path.read_text(encoding="utf-8")
237
+ except OSError:
238
+ existing = ""
239
+ if TICKET_FLOW_QUICKSTART_GENERATED_MARKER not in existing:
240
+ return path
241
+ if existing == content:
242
+ return path
243
+
244
+ path.write_text(content, encoding="utf-8")
245
+ return path
246
+
247
+
248
+ def ensure_tickets_agents_file_for_repo(
249
+ repo_root: Path, *, force: bool = False
250
+ ) -> Path:
251
+ path = repo_root / TICKETS_AGENTS_REL_PATH
252
+ path.parent.mkdir(parents=True, exist_ok=True)
253
+ content = build_tickets_agents_markdown(repo_root=repo_root)
254
+ if content and not content.endswith("\n"):
255
+ content += "\n"
256
+
257
+ if path.exists() and not force:
258
+ try:
259
+ existing = path.read_text(encoding="utf-8")
260
+ except OSError:
261
+ existing = ""
262
+ if TICKETS_AGENTS_GENERATED_MARKER not in existing:
263
+ return path
264
+ if existing == content:
265
+ return path
266
+
267
+ path.write_text(content, encoding="utf-8")
268
+ return path
@@ -0,0 +1,59 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ def extract_turn_id(payload: Any) -> Optional[str]:
5
+ if not isinstance(payload, dict):
6
+ return None
7
+ for key in ("turnId", "turn_id", "id"):
8
+ value = payload.get(key)
9
+ if isinstance(value, str):
10
+ return value
11
+ turn = payload.get("turn")
12
+ if isinstance(turn, dict):
13
+ for key in ("id", "turnId", "turn_id"):
14
+ value = turn.get(key)
15
+ if isinstance(value, str):
16
+ return value
17
+ return None
18
+
19
+
20
+ def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
21
+ if not isinstance(payload, dict):
22
+ return None
23
+ for key in ("threadId", "thread_id"):
24
+ value = payload.get(key)
25
+ if isinstance(value, str):
26
+ return value
27
+ thread = payload.get("thread")
28
+ if isinstance(thread, dict):
29
+ for key in ("id", "threadId", "thread_id"):
30
+ value = thread.get(key)
31
+ if isinstance(value, str):
32
+ return value
33
+ return None
34
+
35
+
36
+ def extract_thread_id_for_turn(payload: Any) -> Optional[str]:
37
+ if not isinstance(payload, dict):
38
+ return None
39
+ for candidate in (payload, payload.get("turn"), payload.get("item")):
40
+ thread_id = _extract_thread_id_from_container(candidate)
41
+ if thread_id:
42
+ return thread_id
43
+ return None
44
+
45
+
46
+ def extract_thread_id(payload: Any) -> Optional[str]:
47
+ if not isinstance(payload, dict):
48
+ return None
49
+ for key in ("threadId", "thread_id", "id"):
50
+ value = payload.get(key)
51
+ if isinstance(value, str):
52
+ return value
53
+ thread = payload.get("thread")
54
+ if isinstance(thread, dict):
55
+ for key in ("id", "threadId", "thread_id"):
56
+ value = thread.get(key)
57
+ if isinstance(value, str):
58
+ return value
59
+ return None
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  from datetime import datetime, timezone
5
6
  from pathlib import Path
6
7
  from typing import Optional
@@ -16,11 +17,17 @@ FILE_CHAT_KEY = "file_chat"
16
17
  FILE_CHAT_OPENCODE_KEY = "file_chat.opencode"
17
18
  FILE_CHAT_PREFIX = "file_chat."
18
19
  FILE_CHAT_OPENCODE_PREFIX = "file_chat.opencode."
20
+ PMA_KEY = "pma"
21
+ PMA_OPENCODE_KEY = "pma.opencode"
22
+
23
+ LOGGER = logging.getLogger("codex_autorunner.app_server")
19
24
 
20
25
  # Static keys that can be reset/managed via the UI.
21
26
  FEATURE_KEYS = {
22
27
  FILE_CHAT_KEY,
23
28
  FILE_CHAT_OPENCODE_KEY,
29
+ PMA_KEY,
30
+ PMA_OPENCODE_KEY,
24
31
  "autorunner",
25
32
  "autorunner.opencode",
26
33
  }
@@ -89,6 +96,8 @@ class AppServerThreadRegistry:
89
96
  "file_chat_opencode": threads.get(FILE_CHAT_OPENCODE_KEY),
90
97
  "autorunner": threads.get("autorunner"),
91
98
  "autorunner_opencode": threads.get("autorunner.opencode"),
99
+ "pma": threads.get(PMA_KEY),
100
+ "pma_opencode": threads.get(PMA_OPENCODE_KEY),
92
101
  }
93
102
  notice = self.corruption_notice()
94
103
  if notice:
@@ -177,8 +186,14 @@ class AppServerThreadRegistry:
177
186
  try:
178
187
  atomic_write(self._notice_path(), json.dumps(notice, indent=2) + "\n")
179
188
  except Exception:
180
- pass
189
+ LOGGER.warning(
190
+ "Failed to write app server thread corruption notice.",
191
+ exc_info=True,
192
+ )
181
193
  try:
182
194
  self._save_unlocked({})
183
195
  except Exception:
184
- pass
196
+ LOGGER.warning(
197
+ "Failed to reset app server thread registry after corruption.",
198
+ exc_info=True,
199
+ )
@@ -0,0 +1,165 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any, Optional, Sequence
4
+
5
+ from .logging_utils import log_event
6
+ from .utils import resolve_executable, subprocess_env
7
+
8
+
9
+ def app_server_env(
10
+ command: Sequence[str],
11
+ cwd: Path,
12
+ *,
13
+ base_env: Optional[dict[str, str]] = None,
14
+ ) -> dict[str, str]:
15
+ extra_paths: list[str] = []
16
+ if command:
17
+ binary = command[0]
18
+ resolved = resolve_executable(binary, env=base_env)
19
+ candidate: Optional[Path] = Path(resolved) if resolved else None
20
+ if candidate is None:
21
+ candidate = Path(binary).expanduser()
22
+ if not candidate.is_absolute():
23
+ candidate = (cwd / candidate).resolve()
24
+ if candidate.exists():
25
+ extra_paths.append(str(candidate.parent))
26
+ return subprocess_env(extra_paths=extra_paths, base_env=base_env)
27
+
28
+
29
+ def seed_codex_home(
30
+ codex_home: Path,
31
+ *,
32
+ logger: Any = None,
33
+ event_prefix: str = "app_server",
34
+ ) -> None:
35
+ logger = logger or __import__("logging").getLogger(__name__)
36
+ auth_path = codex_home / "auth.json"
37
+ source_root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
38
+ if source_root.resolve() == codex_home.resolve():
39
+ return
40
+ source_auth = source_root / "auth.json"
41
+ if auth_path.exists():
42
+ if auth_path.is_symlink() and auth_path.resolve() == source_auth.resolve():
43
+ return
44
+ log_event(
45
+ logger,
46
+ __import__("logging").INFO,
47
+ f"{event_prefix}.codex_home.seed.skipped",
48
+ reason="auth_exists",
49
+ source=str(source_root),
50
+ target=str(codex_home),
51
+ )
52
+ return
53
+ if not source_root.exists():
54
+ log_event(
55
+ logger,
56
+ __import__("logging").WARNING,
57
+ f"{event_prefix}.codex_home.seed.skipped",
58
+ reason="source_missing",
59
+ source=str(source_root),
60
+ target=str(codex_home),
61
+ )
62
+ return
63
+ if not source_auth.exists():
64
+ log_event(
65
+ logger,
66
+ __import__("logging").WARNING,
67
+ f"{event_prefix}.codex_home.seed.skipped",
68
+ reason="auth_missing",
69
+ source=str(source_root),
70
+ target=str(codex_home),
71
+ )
72
+ return
73
+ try:
74
+ auth_path.symlink_to(source_auth)
75
+ log_event(
76
+ logger,
77
+ __import__("logging").INFO,
78
+ f"{event_prefix}.codex_home.seeded",
79
+ source=str(source_root),
80
+ target=str(codex_home),
81
+ )
82
+ except OSError as exc:
83
+ log_event(
84
+ logger,
85
+ __import__("logging").WARNING,
86
+ f"{event_prefix}.codex_home.seed.failed",
87
+ exc=exc,
88
+ source=str(source_root),
89
+ target=str(codex_home),
90
+ )
91
+
92
+
93
+ def build_app_server_env(
94
+ command: Sequence[str],
95
+ workspace_root: Path,
96
+ state_dir: Path,
97
+ *,
98
+ logger: Any = None,
99
+ event_prefix: str = "app_server",
100
+ base_env: Optional[dict[str, str]] = None,
101
+ ) -> dict[str, str]:
102
+ env = app_server_env(command, workspace_root, base_env=base_env)
103
+ codex_home = state_dir / "codex_home"
104
+ codex_home.mkdir(parents=True, exist_ok=True)
105
+ seed_codex_home(codex_home, logger=logger, event_prefix=event_prefix)
106
+ env["CODEX_HOME"] = str(codex_home)
107
+ return env
108
+
109
+
110
+ def _extract_turn_id(payload: Any) -> Optional[str]:
111
+ if not isinstance(payload, dict):
112
+ return None
113
+ for key in ("turnId", "turn_id", "id"):
114
+ value = payload.get(key)
115
+ if isinstance(value, str):
116
+ return value
117
+ turn = payload.get("turn")
118
+ if isinstance(turn, dict):
119
+ for key in ("id", "turnId", "turn_id"):
120
+ value = turn.get(key)
121
+ if isinstance(value, str):
122
+ return value
123
+ return None
124
+
125
+
126
+ def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
127
+ if not isinstance(payload, dict):
128
+ return None
129
+ for key in ("threadId", "thread_id"):
130
+ value = payload.get(key)
131
+ if isinstance(value, str):
132
+ return value
133
+ thread = payload.get("thread")
134
+ if isinstance(thread, dict):
135
+ for key in ("id", "threadId", "thread_id"):
136
+ value = thread.get(key)
137
+ if isinstance(value, str):
138
+ return value
139
+ return None
140
+
141
+
142
+ def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
143
+ if not isinstance(payload, dict):
144
+ return None
145
+ for candidate in (payload, payload.get("turn"), payload.get("item")):
146
+ thread_id = _extract_thread_id_from_container(candidate)
147
+ if thread_id:
148
+ return thread_id
149
+ return None
150
+
151
+
152
+ def _extract_thread_id(payload: Any) -> Optional[str]:
153
+ if not isinstance(payload, dict):
154
+ return None
155
+ for key in ("threadId", "thread_id", "id"):
156
+ value = payload.get(key)
157
+ if isinstance(value, str):
158
+ return value
159
+ thread = payload.get("thread")
160
+ if isinstance(thread, dict):
161
+ for key in ("id", "threadId", "thread_id"):
162
+ value = thread.get(key)
163
+ if isinstance(value, str):
164
+ return value
165
+ return None