codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__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 (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -7,15 +7,24 @@ from .config import (
7
7
  REPO_OVERRIDE_FILENAME,
8
8
  ROOT_CONFIG_FILENAME,
9
9
  ROOT_OVERRIDE_FILENAME,
10
- Config,
11
10
  find_nearest_hub_config_path,
12
11
  )
13
12
 
14
13
  ABOUT_CAR_BASENAME = "ABOUT_CAR.md"
15
14
  ABOUT_CAR_REL_PATH = Path(".codex-autorunner") / ABOUT_CAR_BASENAME
15
+ TICKET_FLOW_QUICKSTART_BASENAME = "TICKET_FLOW_QUICKSTART.md"
16
+ TICKET_FLOW_QUICKSTART_REL_PATH = (
17
+ Path(".codex-autorunner") / TICKET_FLOW_QUICKSTART_BASENAME
18
+ )
19
+ TICKETS_AGENTS_BASENAME = "AGENTS.md"
20
+ TICKETS_AGENTS_REL_PATH = (
21
+ Path(".codex-autorunner") / "tickets" / TICKETS_AGENTS_BASENAME
22
+ )
16
23
 
17
24
  # If this marker is present, codex-autorunner may safely refresh the file content.
18
25
  ABOUT_CAR_GENERATED_MARKER = "<!-- CAR:AUTOGENERATED -->"
26
+ TICKET_FLOW_QUICKSTART_GENERATED_MARKER = "<!-- CAR:TICKET_FLOW_QUICKSTART -->"
27
+ TICKETS_AGENTS_GENERATED_MARKER = "<!-- CAR:TICKETS_AGENTS -->"
19
28
 
20
29
  CAR_CONTEXT_KEYWORDS = (
21
30
  "car",
@@ -76,8 +85,11 @@ def build_about_car_markdown(
76
85
  "You are running inside **Codex Autorunner (CAR)**.\n\n"
77
86
  "CAR uses a ticket-first workflow.\n\n"
78
87
  "## Required for operation\n"
79
- "- Tickets live under `.codex-autorunner/tickets/`.\n"
88
+ "- Tickets live under `.codex-autorunner/tickets/` (per-repo/worktree).\n"
89
+ "- If the user provides ticket files, place them in the repo's `.codex-autorunner/tickets/` folder.\n"
80
90
  "- Lint ticket frontmatter after edits (runs against all tickets): `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
91
+ "## Ticket flow quickstart\n"
92
+ f"- Read `{_display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)}` for CLI entrypoints + gotchas.\n\n"
81
93
  "## Optional workspace docs\n"
82
94
  "- **Active context**: "
83
95
  f"`{active_context_disp}`\n"
@@ -85,6 +97,18 @@ def build_about_car_markdown(
85
97
  f"`{decisions_disp}`\n"
86
98
  "- **Spec**: "
87
99
  f"`{spec_disp}`\n\n"
100
+ "## Web UI quick map (repo page)\n"
101
+ "- Repo view: `/repos/<repo_id>/`\n"
102
+ "- Tabs: **Tickets** = `.codex-autorunner/tickets/` queue.\n"
103
+ "- Tabs: **Inbox** = paused run dispatches/handoffs.\n"
104
+ "- Tabs: **Workspace** = edit `active_context.md`, `spec.md`, `decisions.md`.\n"
105
+ "- Tabs: **Terminal** = launches the configured `codex` binary in a PTY.\n"
106
+ "- Tabs: **Archive** = browse worktree snapshots.\n\n"
107
+ "## FileBox (attachments)\n"
108
+ "- Repo FileBox root: `.codex-autorunner/filebox/`.\n"
109
+ "- User uploads: `.codex-autorunner/filebox/inbox/`.\n"
110
+ "- Files to send back: `.codex-autorunner/filebox/outbox/`.\n"
111
+ "- Note: ticket_flow uses per-run dispatch directories; do not confuse dispatch with FileBox.\n\n"
88
112
  "## Critical rules\n"
89
113
  "- Do **not** create new copies of workspace docs elsewhere in the repo.\n"
90
114
  "- Treat `.codex-autorunner/` as intentional project structure even though it is hidden/gitignored.\n\n"
@@ -96,6 +120,12 @@ def build_about_car_markdown(
96
120
  "- Use `.codex-autorunner/bin/ticket_tool.py` to list/create/insert/move tickets; it is portable and venv-free.\n"
97
121
  '- 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'
98
122
  "- After any ticket edits, lint all tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
123
+ "## Ticket templates (optional)\n"
124
+ "- CAR can fetch ticket templates from configured git repos (treat templates as code).\n"
125
+ "- Fetch (prints template to stdout): `car templates fetch <repo_id>:<path>[@<ref>]`\n"
126
+ "- Pin to a commit for determinism: `...@<commit_sha>`\n"
127
+ "- Trusted repos skip scanning. Untrusted repos are scanned (cached by blob SHA) before content is returned.\n"
128
+ "- If fetch or scan fails, pause and notify the user rather than guessing.\n\n"
99
129
  "## How CAR works (short)\n"
100
130
  "- The web UI provides ticket editing + unified file chat.\n"
101
131
  "- `car serve` starts the hub web UI. The **Terminal** tab launches the configured `codex` binary in a PTY.\n"
@@ -144,12 +174,95 @@ def ensure_about_car_file_for_repo(
144
174
  return path
145
175
 
146
176
 
147
- def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
148
- """Config-aware wrapper that uses configured doc paths."""
149
- repo_root = config.root
150
- docs = {
151
- "active_context": config.doc_path("active_context"),
152
- "decisions": config.doc_path("decisions"),
153
- "spec": config.doc_path("spec"),
154
- }
155
- return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
177
+ def build_ticket_flow_quickstart_markdown(*, repo_root: Path) -> str:
178
+ ticket_dir = ".codex-autorunner/tickets/"
179
+ return (
180
+ f"{TICKET_FLOW_QUICKSTART_GENERATED_MARKER}\n"
181
+ "# Ticket Flow Quickstart\n\n"
182
+ "## Start ticket flow via CLI\n"
183
+ "- Bootstrap the first run (creates TICKET-001 if needed):\n"
184
+ " `car flow ticket_flow bootstrap --repo <path>`\n"
185
+ " (alias: `car ticket-flow bootstrap --repo <path>`)\n"
186
+ "- Start/resume without seeding tickets:\n"
187
+ " `car flow ticket_flow start --repo <path>`\n"
188
+ "- Check status:\n"
189
+ " `car flow ticket_flow status --repo <path> [--run-id <uuid>]`\n"
190
+ "- Resume/stop:\n"
191
+ " `car flow ticket_flow resume --repo <path> [--run-id <uuid>]`\n"
192
+ " `car flow ticket_flow stop --repo <path> [--run-id <uuid>]`\n\n"
193
+ "## Where tickets live\n"
194
+ f"- Tickets are per-repo/worktree under `{ticket_dir}`.\n"
195
+ "- If the user provides ticket files, save them directly into that folder.\n\n"
196
+ "## Common gotchas\n"
197
+ "- Hub vs repo: ticket flows run per-repo; CLI commands need a repo path.\n"
198
+ "- `--repo` expects a filesystem path, not a hub repo_id.\n"
199
+ "- Each worktree has its own `.codex-autorunner/` directory.\n"
200
+ "- 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"
201
+ )
202
+
203
+
204
+ def build_tickets_agents_markdown(*, repo_root: Path) -> str:
205
+ quickstart_path = _display_path(repo_root, TICKET_FLOW_QUICKSTART_REL_PATH)
206
+ return (
207
+ f"{TICKETS_AGENTS_GENERATED_MARKER}\n"
208
+ "# Tickets — AGENTS\n\n"
209
+ "This folder is the authoritative ticket queue for this repo/worktree.\n\n"
210
+ "## Ticket files\n"
211
+ "- Store work items as `TICKET-###*.md` (ordered by number).\n"
212
+ "- Keep frontmatter `done: true|false` in sync with completion.\n"
213
+ "- After edits, lint tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
214
+ "## Ticket CLI (portable)\n"
215
+ "- List: `python3 .codex-autorunner/bin/ticket_tool.py list`\n"
216
+ '- Create: `python3 .codex-autorunner/bin/ticket_tool.py create --title "..." --agent codex`\n'
217
+ "- Insert gap: `python3 .codex-autorunner/bin/ticket_tool.py insert --before N`\n"
218
+ "- Move block: `python3 .codex-autorunner/bin/ticket_tool.py move --start A --end B --to T`\n"
219
+ "- Lint: `python3 .codex-autorunner/bin/ticket_tool.py lint`\n\n"
220
+ "## Ticket flow (runner)\n"
221
+ f"- See `{quickstart_path}` for `car flow ticket_flow ...` commands.\n"
222
+ )
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
@@ -17,6 +17,8 @@ FILE_CHAT_KEY = "file_chat"
17
17
  FILE_CHAT_OPENCODE_KEY = "file_chat.opencode"
18
18
  FILE_CHAT_PREFIX = "file_chat."
19
19
  FILE_CHAT_OPENCODE_PREFIX = "file_chat.opencode."
20
+ PMA_KEY = "pma"
21
+ PMA_OPENCODE_KEY = "pma.opencode"
20
22
 
21
23
  LOGGER = logging.getLogger("codex_autorunner.app_server")
22
24
 
@@ -24,6 +26,8 @@ LOGGER = logging.getLogger("codex_autorunner.app_server")
24
26
  FEATURE_KEYS = {
25
27
  FILE_CHAT_KEY,
26
28
  FILE_CHAT_OPENCODE_KEY,
29
+ PMA_KEY,
30
+ PMA_OPENCODE_KEY,
27
31
  "autorunner",
28
32
  "autorunner.opencode",
29
33
  }
@@ -92,6 +96,8 @@ class AppServerThreadRegistry:
92
96
  "file_chat_opencode": threads.get(FILE_CHAT_OPENCODE_KEY),
93
97
  "autorunner": threads.get("autorunner"),
94
98
  "autorunner_opencode": threads.get("autorunner.opencode"),
99
+ "pma": threads.get(PMA_KEY),
100
+ "pma_opencode": threads.get(PMA_OPENCODE_KEY),
95
101
  }
96
102
  notice = self.corruption_notice()
97
103
  if notice:
@@ -12,6 +12,7 @@ import yaml
12
12
 
13
13
  from ..housekeeping import HousekeepingConfig, parse_housekeeping_config
14
14
  from .path_utils import ConfigPathError, resolve_config_path
15
+ from .utils import atomic_write
15
16
 
16
17
  logger = logging.getLogger("codex_autorunner.core.config")
17
18
 
@@ -138,6 +139,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
138
139
  "approval_mode": "yolo",
139
140
  # Keep ticket_flow deterministic by default; surfaces can tighten this.
140
141
  "default_approval_decision": "accept",
142
+ "include_previous_ticket_context": False,
141
143
  },
142
144
  "git": {
143
145
  "auto_commit": False,
@@ -197,6 +199,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
197
199
  },
198
200
  "opencode": {
199
201
  "session_stall_timeout_seconds": 60,
202
+ "max_text_chars": 20000,
200
203
  },
201
204
  "usage": {
202
205
  "cache_scope": "global",
@@ -255,7 +258,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
255
258
  "files": True,
256
259
  "max_image_bytes": 10_000_000,
257
260
  "max_voice_bytes": 10_000_000,
258
- "max_file_bytes": 10_000_000,
261
+ "max_file_bytes": 100_000_000,
259
262
  "image_prompt": (
260
263
  "The user sent an image with no caption. Use it to continue the "
261
264
  "conversation; if no clear task, describe the image and ask what "
@@ -425,6 +428,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
425
428
  },
426
429
  ],
427
430
  },
431
+ "storage": {
432
+ "durable_writes": False,
433
+ },
428
434
  }
429
435
 
430
436
  REPO_DEFAULT_KEYS = {
@@ -460,12 +466,38 @@ REPO_SHARED_KEYS = {
460
466
  "housekeeping",
461
467
  "update",
462
468
  "usage",
469
+ "templates",
463
470
  }
464
471
 
465
472
  DEFAULT_HUB_CONFIG: Dict[str, Any] = {
466
473
  "version": CONFIG_VERSION,
467
474
  "mode": "hub",
468
475
  "repo_defaults": DEFAULT_REPO_DEFAULTS,
476
+ "pma": {
477
+ "enabled": True,
478
+ "default_agent": "codex",
479
+ "model": None,
480
+ "reasoning": None,
481
+ "max_upload_bytes": 10_000_000,
482
+ "max_repos": 25,
483
+ "max_messages": 10,
484
+ "max_text_chars": 800,
485
+ # PMA durable workspace docs (hub-level)
486
+ "docs_max_chars": 12_000,
487
+ "active_context_max_lines": 200,
488
+ "context_log_tail_lines": 120,
489
+ },
490
+ "templates": {
491
+ "enabled": True,
492
+ "repos": [
493
+ {
494
+ "id": "blessed",
495
+ "url": "https://github.com/Git-on-my-level/car-ticket-templates",
496
+ "trusted": True,
497
+ "default_ref": "main",
498
+ }
499
+ ],
500
+ },
469
501
  "agents": {
470
502
  "codex": {
471
503
  "binary": "codex",
@@ -510,7 +542,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
510
542
  "files": True,
511
543
  "max_image_bytes": 10_000_000,
512
544
  "max_voice_bytes": 10_000_000,
513
- "max_file_bytes": 10_000_000,
545
+ "max_file_bytes": 100_000_000,
514
546
  "image_prompt": (
515
547
  "The user sent an image with no caption. Use it to continue the "
516
548
  "conversation; if no clear task, describe the image and ask what "
@@ -618,6 +650,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
618
650
  },
619
651
  "opencode": {
620
652
  "session_stall_timeout_seconds": 60,
653
+ "max_text_chars": 20000,
621
654
  },
622
655
  "usage": {
623
656
  "cache_scope": "global",
@@ -724,6 +757,9 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
724
757
  },
725
758
  ],
726
759
  },
760
+ "storage": {
761
+ "durable_writes": False,
762
+ },
727
763
  }
728
764
 
729
765
 
@@ -810,6 +846,24 @@ class AppServerConfig:
810
846
  @dataclasses.dataclass
811
847
  class OpenCodeConfig:
812
848
  session_stall_timeout_seconds: Optional[float]
849
+ max_text_chars: Optional[int]
850
+
851
+
852
+ @dataclasses.dataclass
853
+ class PmaConfig:
854
+ enabled: bool
855
+ default_agent: str
856
+ model: Optional[str]
857
+ reasoning: Optional[str]
858
+ max_upload_bytes: int
859
+ max_repos: int
860
+ max_messages: int
861
+ max_text_chars: int
862
+ # Hub-level PMA durable workspace docs
863
+ docs_max_chars: int = 12_000
864
+ active_context_max_lines: int = 200
865
+ context_log_tail_lines: int = 120
866
+ dispatch_interception_enabled: bool = False
813
867
 
814
868
 
815
869
  @dataclasses.dataclass
@@ -827,6 +881,20 @@ class AgentConfig:
827
881
  subagent_models: Optional[Dict[str, str]]
828
882
 
829
883
 
884
+ @dataclasses.dataclass(frozen=True)
885
+ class TemplateRepoConfig:
886
+ id: str
887
+ url: str
888
+ trusted: bool
889
+ default_ref: str
890
+
891
+
892
+ @dataclasses.dataclass(frozen=True)
893
+ class TemplatesConfig:
894
+ enabled: bool
895
+ repos: List[TemplateRepoConfig]
896
+
897
+
830
898
  @dataclasses.dataclass
831
899
  class RepoConfig:
832
900
  raw: Dict[str, Any]
@@ -869,6 +937,8 @@ class RepoConfig:
869
937
  voice: Dict[str, Any]
870
938
  static_assets: StaticAssetsConfig
871
939
  housekeeping: HousekeepingConfig
940
+ durable_writes: bool
941
+ templates: TemplatesConfig
872
942
 
873
943
  def doc_path(self, key: str) -> Path:
874
944
  return self.root / self.docs[key]
@@ -894,6 +964,7 @@ class HubConfig:
894
964
  mode: str
895
965
  repo_defaults: Dict[str, Any]
896
966
  agents: Dict[str, AgentConfig]
967
+ templates: TemplatesConfig
897
968
  repos_root: Path
898
969
  worktrees_root: Path
899
970
  manifest_path: Path
@@ -905,6 +976,7 @@ class HubConfig:
905
976
  update_skip_checks: bool
906
977
  app_server: AppServerConfig
907
978
  opencode: OpenCodeConfig
979
+ pma: PmaConfig
908
980
  usage: UsageConfig
909
981
  server_host: str
910
982
  server_port: int
@@ -917,6 +989,7 @@ class HubConfig:
917
989
  server_log: LogConfig
918
990
  static_assets: StaticAssetsConfig
919
991
  housekeeping: HousekeepingConfig
992
+ durable_writes: bool
920
993
 
921
994
  def agent_binary(self, agent_id: str) -> str:
922
995
  agent = self.agents.get(agent_id)
@@ -977,6 +1050,23 @@ def _load_root_config(root: Path) -> Dict[str, Any]:
977
1050
  return merged
978
1051
 
979
1052
 
1053
+ def update_override_templates(repo_root: Path, repos: List[Dict[str, Any]]) -> None:
1054
+ """
1055
+ Update templates.repos in the root override file, preserving other settings.
1056
+
1057
+ This writes to `codex-autorunner.override.yml` (gitignored) at the provided repo_root.
1058
+ """
1059
+ override_path = repo_root / ROOT_OVERRIDE_FILENAME
1060
+ data = _load_yaml_dict(override_path)
1061
+ templates = data.get("templates")
1062
+ if templates is None or not isinstance(templates, dict):
1063
+ templates = {}
1064
+ data["templates"] = templates
1065
+ templates["repos"] = list(repos or [])
1066
+ rendered = yaml.safe_dump(data, sort_keys=False).rstrip() + "\n"
1067
+ atomic_write(override_path, rendered)
1068
+
1069
+
980
1070
  def load_root_defaults(root: Path) -> Dict[str, Any]:
981
1071
  """Load hub defaults from the root config + override file."""
982
1072
  return _load_root_config(root)
@@ -1271,7 +1361,77 @@ def _parse_opencode_config(
1271
1361
  )
1272
1362
  if stall_timeout_seconds is not None and stall_timeout_seconds <= 0:
1273
1363
  stall_timeout_seconds = None
1274
- return OpenCodeConfig(session_stall_timeout_seconds=stall_timeout_seconds)
1364
+ max_text_chars_raw = cfg.get("max_text_chars", defaults.get("max_text_chars"))
1365
+ max_text_chars = (
1366
+ int(max_text_chars_raw)
1367
+ if isinstance(max_text_chars_raw, int) and max_text_chars_raw > 0
1368
+ else None
1369
+ )
1370
+ return OpenCodeConfig(
1371
+ session_stall_timeout_seconds=stall_timeout_seconds,
1372
+ max_text_chars=max_text_chars,
1373
+ )
1374
+
1375
+
1376
+ def _parse_pma_config(
1377
+ cfg: Optional[Dict[str, Any]],
1378
+ _root: Path,
1379
+ defaults: Optional[Dict[str, Any]],
1380
+ ) -> PmaConfig:
1381
+ cfg = cfg if isinstance(cfg, dict) else {}
1382
+ defaults = defaults if isinstance(defaults, dict) else {}
1383
+ enabled = bool(cfg.get("enabled", defaults.get("enabled", True)))
1384
+ default_agent = str(
1385
+ cfg.get("default_agent", defaults.get("default_agent", "codex"))
1386
+ )
1387
+ model_raw = cfg.get("model", defaults.get("model"))
1388
+ model = str(model_raw).strip() or None if model_raw else None
1389
+ reasoning_raw = cfg.get("reasoning", defaults.get("reasoning"))
1390
+ reasoning = str(reasoning_raw).strip() or None if reasoning_raw else None
1391
+ max_upload_bytes_raw = cfg.get(
1392
+ "max_upload_bytes", defaults.get("max_upload_bytes", 10_000_000)
1393
+ )
1394
+ try:
1395
+ max_upload_bytes = int(max_upload_bytes_raw)
1396
+ except (ValueError, TypeError):
1397
+ max_upload_bytes = 10_000_000
1398
+ if max_upload_bytes <= 0:
1399
+ max_upload_bytes = 10_000_000
1400
+
1401
+ def _parse_positive_int(key: str, fallback: int) -> int:
1402
+ raw = cfg.get(key, defaults.get(key, fallback))
1403
+ try:
1404
+ value = int(raw)
1405
+ except (ValueError, TypeError):
1406
+ return fallback
1407
+ return value if value > 0 else fallback
1408
+
1409
+ max_repos = _parse_positive_int("max_repos", 25)
1410
+ max_messages = _parse_positive_int("max_messages", 10)
1411
+ max_text_chars = _parse_positive_int("max_text_chars", 800)
1412
+ docs_max_chars = _parse_positive_int("docs_max_chars", 12_000)
1413
+ active_context_max_lines = _parse_positive_int("active_context_max_lines", 200)
1414
+ context_log_tail_lines = _parse_positive_int("context_log_tail_lines", 120)
1415
+ dispatch_interception_enabled = bool(
1416
+ cfg.get(
1417
+ "dispatch_interception_enabled",
1418
+ defaults.get("dispatch_interception_enabled", False),
1419
+ )
1420
+ )
1421
+ return PmaConfig(
1422
+ enabled=enabled,
1423
+ default_agent=default_agent,
1424
+ model=model,
1425
+ reasoning=reasoning,
1426
+ max_upload_bytes=max_upload_bytes,
1427
+ max_repos=max_repos,
1428
+ max_messages=max_messages,
1429
+ max_text_chars=max_text_chars,
1430
+ docs_max_chars=docs_max_chars,
1431
+ active_context_max_lines=active_context_max_lines,
1432
+ context_log_tail_lines=context_log_tail_lines,
1433
+ dispatch_interception_enabled=dispatch_interception_enabled,
1434
+ )
1275
1435
 
1276
1436
 
1277
1437
  def _parse_usage_config(
@@ -1337,6 +1497,55 @@ def _parse_agents_config(
1337
1497
  return agents
1338
1498
 
1339
1499
 
1500
+ def _parse_templates_config(
1501
+ cfg: Optional[Dict[str, Any]],
1502
+ defaults: Optional[Dict[str, Any]],
1503
+ ) -> TemplatesConfig:
1504
+ cfg = cfg if isinstance(cfg, dict) else {}
1505
+ defaults = defaults if isinstance(defaults, dict) else {}
1506
+ enabled_raw = cfg.get("enabled", defaults.get("enabled", True))
1507
+ if "enabled" in cfg and not isinstance(enabled_raw, bool):
1508
+ raise ConfigError("templates.enabled must be boolean")
1509
+ enabled = bool(enabled_raw)
1510
+ repos_raw = cfg.get("repos", defaults.get("repos", []))
1511
+ if repos_raw is None:
1512
+ repos_raw = []
1513
+ if not isinstance(repos_raw, list):
1514
+ raise ConfigError("templates.repos must be a list")
1515
+ repos: List[TemplateRepoConfig] = []
1516
+ seen_ids: set[str] = set()
1517
+ for idx, repo in enumerate(repos_raw):
1518
+ if not isinstance(repo, dict):
1519
+ raise ConfigError(f"templates.repos[{idx}] must be a mapping")
1520
+ repo_id = repo.get("id")
1521
+ if not isinstance(repo_id, str) or not repo_id.strip():
1522
+ raise ConfigError(f"templates.repos[{idx}].id must be a non-empty string")
1523
+ repo_id = repo_id.strip()
1524
+ if repo_id in seen_ids:
1525
+ raise ConfigError(f"templates.repos[{idx}].id must be unique")
1526
+ seen_ids.add(repo_id)
1527
+ url = repo.get("url")
1528
+ if not isinstance(url, str) or not url.strip():
1529
+ raise ConfigError(f"templates.repos[{idx}].url must be a non-empty string")
1530
+ trusted = repo.get("trusted", False)
1531
+ if "trusted" in repo and not isinstance(trusted, bool):
1532
+ raise ConfigError(f"templates.repos[{idx}].trusted must be boolean")
1533
+ default_ref = repo.get("default_ref", "main")
1534
+ if not isinstance(default_ref, str) or not default_ref.strip():
1535
+ raise ConfigError(
1536
+ f"templates.repos[{idx}].default_ref must be a non-empty string"
1537
+ )
1538
+ repos.append(
1539
+ TemplateRepoConfig(
1540
+ id=repo_id,
1541
+ url=url.strip(),
1542
+ trusted=bool(trusted),
1543
+ default_ref=default_ref.strip(),
1544
+ )
1545
+ )
1546
+ return TemplatesConfig(enabled=enabled, repos=repos)
1547
+
1548
+
1340
1549
  def _parse_static_assets_config(
1341
1550
  cfg: Optional[Dict[str, Any]],
1342
1551
  root: Path,
@@ -1628,6 +1837,11 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1628
1837
  autorunner_reuse_session = (
1629
1838
  bool(reuse_session_value) if reuse_session_value is not None else False
1630
1839
  )
1840
+ storage_cfg = cfg.get("storage")
1841
+ storage_cfg = cast(
1842
+ Dict[str, Any], storage_cfg if isinstance(storage_cfg, dict) else {}
1843
+ )
1844
+ durable_writes = bool(storage_cfg.get("durable_writes", False))
1631
1845
  return RepoConfig(
1632
1846
  raw=cfg,
1633
1847
  root=root,
@@ -1701,6 +1915,10 @@ def _build_repo_config(config_path: Path, cfg: Dict[str, Any]) -> RepoConfig:
1701
1915
  cfg.get("static_assets"), root, DEFAULT_REPO_CONFIG["static_assets"]
1702
1916
  ),
1703
1917
  housekeeping=parse_housekeeping_config(cfg.get("housekeeping")),
1918
+ durable_writes=durable_writes,
1919
+ templates=_parse_templates_config(
1920
+ cfg.get("templates"), DEFAULT_HUB_CONFIG.get("templates")
1921
+ ),
1704
1922
  )
1705
1923
 
1706
1924
 
@@ -1738,6 +1956,11 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1738
1956
  Dict[str, Any], update_cfg if isinstance(update_cfg, dict) else {}
1739
1957
  )
1740
1958
  update_skip_checks = bool(update_cfg.get("skip_checks", False))
1959
+ storage_cfg = cfg.get("storage")
1960
+ storage_cfg = cast(
1961
+ Dict[str, Any], storage_cfg if isinstance(storage_cfg, dict) else {}
1962
+ )
1963
+ durable_writes = bool(storage_cfg.get("durable_writes", False))
1741
1964
 
1742
1965
  return HubConfig(
1743
1966
  raw=cfg,
@@ -1746,6 +1969,9 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1746
1969
  mode="hub",
1747
1970
  repo_defaults=cast(Dict[str, Any], cfg.get("repo_defaults") or {}),
1748
1971
  agents=_parse_agents_config(cfg, DEFAULT_HUB_CONFIG),
1972
+ templates=_parse_templates_config(
1973
+ cfg.get("templates"), DEFAULT_HUB_CONFIG.get("templates")
1974
+ ),
1749
1975
  repos_root=(root / hub_cfg["repos_root"]).resolve(),
1750
1976
  worktrees_root=(root / hub_cfg["worktrees_root"]).resolve(),
1751
1977
  manifest_path=root / hub_cfg["manifest"],
@@ -1755,6 +1981,7 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1755
1981
  update_repo_url=str(hub_cfg.get("update_repo_url", "")),
1756
1982
  update_repo_ref=str(hub_cfg.get("update_repo_ref", "main")),
1757
1983
  update_skip_checks=update_skip_checks,
1984
+ durable_writes=durable_writes,
1758
1985
  app_server=_parse_app_server_config(
1759
1986
  cfg.get("app_server"),
1760
1987
  root,
@@ -1763,6 +1990,7 @@ def _build_hub_config(config_path: Path, cfg: Dict[str, Any]) -> HubConfig:
1763
1990
  opencode=_parse_opencode_config(
1764
1991
  cfg.get("opencode"), root, DEFAULT_HUB_CONFIG.get("opencode")
1765
1992
  ),
1993
+ pma=_parse_pma_config(cfg.get("pma"), root, DEFAULT_HUB_CONFIG.get("pma")),
1766
1994
  usage=_parse_usage_config(
1767
1995
  cfg.get("usage"), root, DEFAULT_HUB_CONFIG.get("usage")
1768
1996
  ),
@@ -1969,6 +2197,13 @@ def _validate_opencode_config(cfg: Dict[str, Any]) -> None:
1969
2197
  raise ConfigError(
1970
2198
  "opencode.session_stall_timeout_seconds must be a number or null"
1971
2199
  )
2200
+ if (
2201
+ "max_text_chars" in opencode_cfg
2202
+ and opencode_cfg.get("max_text_chars") is not None
2203
+ ):
2204
+ max_text_chars = opencode_cfg.get("max_text_chars")
2205
+ if not isinstance(max_text_chars, int):
2206
+ raise ConfigError("opencode.max_text_chars must be an integer or null")
1972
2207
 
1973
2208
 
1974
2209
  def _validate_update_config(cfg: Dict[str, Any]) -> None:
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ CAR_AWARENESS_BLOCK = """<injected context>
6
+ You are operating inside a Codex Autorunner (CAR) managed repo.
7
+
8
+ CAR’s durable control-plane lives under `.codex-autorunner/`:
9
+ - `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing (ticket/workspace conventions + helper scripts).
10
+ - `.codex-autorunner/tickets/` — ordered ticket queue (`TICKET-###*.md`) used by the ticket flow runner.
11
+ - `.codex-autorunner/workspace/` — shared context docs:
12
+ - `active_context.md` — current “north star” context; kept fresh for ongoing work.
13
+ - `spec.md` — longer spec / acceptance criteria when needed.
14
+ - `decisions.md` — prior decisions / tradeoffs when relevant.
15
+ - `.codex-autorunner/filebox/` — attachments inbox/outbox used by CAR surfaces (if present).
16
+
17
+ Intent signals: if the user mentions tickets, “dispatch”, “resume”, workspace docs, or `.codex-autorunner/`, they are likely referring to CAR artifacts/workflow rather than generic repo files.
18
+
19
+ Use the above as orientation. If you need the operational details (exact helper commands, what CAR auto-generates), read `.codex-autorunner/ABOUT_CAR.md`.
20
+ </injected context>"""
21
+
22
+ ROLE_ADDENDUM_START = "<role addendum>"
23
+ ROLE_ADDENDUM_END = "</role addendum>"
24
+
25
+
26
+ def format_file_role_addendum(
27
+ kind: Literal["ticket", "workspace", "other"],
28
+ rel_path: str,
29
+ ) -> str:
30
+ """Format a short role-specific addendum for prompts."""
31
+ if kind == "ticket":
32
+ text = f"This target is a CAR ticket at `{rel_path}`."
33
+ elif kind == "workspace":
34
+ text = f"This target is a CAR workspace doc at `{rel_path}`."
35
+ elif kind == "other":
36
+ text = f"This target file is `{rel_path}`."
37
+ else:
38
+ raise ValueError(f"Unsupported role addendum kind: {kind}")
39
+ return f"{ROLE_ADDENDUM_START}\n{text}\n{ROLE_ADDENDUM_END}"