code-coordinator 0.5.46__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 (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,610 @@
1
+ """ClaudePtyProvider: interactive ``claude`` driven through a pseudo-terminal.
2
+
3
+ This is the **metering escape hatch** introduced for #425. After 2026-06-15,
4
+ ``claude -p`` (the :class:`~.claude.ClaudeProvider` backend) is billed at full
5
+ API rates against a non-rolling credit pool — see #322. Interactive
6
+ ``claude`` (no ``-p``) is still covered by the Max/Pro subscription, so the
7
+ coordinator exposes it as a separate provider that **opt-in** users can
8
+ select via ``coordinator.yml`` ``providers`` config or per-spec
9
+ ``provider="claude-pty"``.
10
+
11
+ Differences from :class:`~.claude.ClaudeProvider`:
12
+
13
+ * ``build_command()`` does **not** pass ``-p`` and does **not** use
14
+ ``stream-json`` formats. Interactive ``claude`` reads typed input from a
15
+ TTY and renders TUI output — the agent attaches the worker to a PTY
16
+ (:mod:`pty`) in :meth:`coord.agent.AgentServer._spawn_pty`.
17
+ * ``initial_input()`` returns the briefing as **plain text + newline** (the
18
+ bytes typed into the TTY), not a stream-json envelope.
19
+ * ``capabilities()`` reports ``billing_mode="subscription"`` (this is the
20
+ whole point) and conservatively reports ``enforces_deny_list=False`` —
21
+ the interactive PTY path has **not been verified** to honour
22
+ ``--allowedTools`` / ``--permission-mode`` in this PR, so the agent's
23
+ safety gate refuses to run write-capable assignment types (``work``,
24
+ ``review``, ``smoke``, ``conflict-fix``) on this provider until that
25
+ verification lands. Non-mutating assignment types (``plan``,
26
+ ``refinement``, ``test-chat``, ``new-issue-chat``) may still use it.
27
+ * ``parse_log()`` delegates to :func:`coord.worker_events.parse_log` so it
28
+ degrades gracefully when the log contains raw TTY bytes rather than
29
+ stream-json — :func:`~coord.worker_events.parse_log` silently skips
30
+ non-JSON lines and returns a blank :class:`WorkerSummary`.
31
+
32
+ Imports from :mod:`coord.agent` are deferred (inside method bodies) to keep
33
+ the import cycle latent, matching the pattern in
34
+ :class:`~.claude.ClaudeProvider`.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING
42
+
43
+ from coord.providers.base import Capabilities, Provider, WorkerSummary
44
+
45
+ if TYPE_CHECKING:
46
+ from coord.agent import AssignmentSpec
47
+
48
+
49
+ # Sentinel string written to the log file by the PTY spawn path when the
50
+ # worker exits. Used as :meth:`ClaudePtyProvider.result_marker` because the
51
+ # interactive ``claude`` CLI does NOT emit a stream-json ``result`` event,
52
+ # so the standard ``"type":"result"`` marker never appears.
53
+ #
54
+ # MUST stay byte-equal to ``coord.agent._PTY_RESULT_LINE_MARKER`` (which is
55
+ # the bytes form of this constant — kept separate there to dodge a
56
+ # module-level import cycle). The sync is asserted in
57
+ # ``tests/test_agent_reap.py::test_pty_marker_bytes_sync_with_provider_string``.
58
+ PTY_RESULT_MARKER = "# pty: worker exited"
59
+
60
+ # Briefing-submission control bytes for the interactive TUI (#425).
61
+ #
62
+ # A multi-line briefing MUST be delivered inside a bracketed-paste block
63
+ # (``ESC[200~`` ... ``ESC[201~``) — otherwise the TUI treats each embedded
64
+ # newline as a soft line break and the message is never submitted. The
65
+ # submitting Enter is a CARRIAGE RETURN (``\r``); ``\n`` does NOT submit under
66
+ # the TUI's enhanced (kitty) keyboard protocol. The CR must be written
67
+ # *separately*, after a short settle delay, by
68
+ # :meth:`coord.agent.AgentServer._spawn_pty` — a CR glued onto ``ESC[201~`` in
69
+ # the same write is swallowed by paste-end processing. ``initial_input``
70
+ # returns only the bracketed-paste block; the spawn path appends the CR.
71
+ # Verified live against interactive ``claude`` v2.1.165 (#425 smoke: raw
72
+ # multi-line never submits; bracketed-paste + separate CR reliably does).
73
+ BRACKETED_PASTE_START = b"\x1b[200~"
74
+ BRACKETED_PASTE_END = b"\x1b[201~"
75
+
76
+ # DECSET emitted by the TUI once it has ENABLED bracketed-paste input — the
77
+ # spawn path waits for this (then for render quiescence) before pasting, so the
78
+ # briefing is never sent before the TUI can accept it (the enable arrives
79
+ # ~0.85s after first output, while the TUI is still drawing; pasting then is
80
+ # silently dropped).
81
+ BRACKETED_PASTE_ENABLE = b"\x1b[?2004h"
82
+
83
+ # ── #865: readiness-anchor + paste verification primitives ───────────────────
84
+ #
85
+ # Root cause of #865: both injection paths (the tmux path in
86
+ # ``coord.interactive`` and the PTY-relay paths in ``coord.interactive`` /
87
+ # ``coord.agent``) used render QUIESCENCE as their only proxy for "the input
88
+ # box is ready", then pasted once and never checked whether the text actually
89
+ # landed. Claude Code's TUI paints async startup content (promo banners,
90
+ # MCP/auth notices, update notices) over several seconds — quiescence alone
91
+ # cannot tell "static banner mid-startup" from "input box settled", and a
92
+ # late repaint arriving right around the paste can silently discard it.
93
+ #
94
+ # The fix has two parts, both anchored on primitives defined ONCE here so the
95
+ # three call-sites (tmux capture-pane text, the interactive PTY relay's
96
+ # in-memory screen buffer, and the agent's PTY log tail) can't drift out of
97
+ # sync the way the pre-#865 "twin implementations" did:
98
+ #
99
+ # 1. ``INPUT_BOX_MARKER`` — the rendered left border of the TUI's input box
100
+ # (``"❯ Try ..."`` in the #865 capture). Callers require this to be
101
+ # present (in addition to quiescence) before treating the screen as
102
+ # "ready" — so we don't paste into a still-loading/blank frame that
103
+ # merely *looks* stable. If the marker never appears (older CLI, unusual
104
+ # render), callers still fall back to their existing timeout/cap so a
105
+ # mismatch degrades to the pre-#865 behaviour rather than hanging.
106
+ #
107
+ # 2. ``briefing_fingerprint`` / ``fingerprint_in_text`` / ``fingerprint_in_bytes``
108
+ # — after pasting, re-observe the screen and confirm a snippet of the
109
+ # briefing actually rendered. Whitespace is collapsed on both sides
110
+ # because the terminal re-wraps pasted text at arbitrary column widths,
111
+ # so an exact match against the original briefing would spuriously fail.
112
+ # Callers retry the paste (bounded, with backoff) on a miss and log a
113
+ # hard failure if every attempt misses — never silent.
114
+ INPUT_BOX_MARKER = "❯"
115
+ INPUT_BOX_MARKER_BYTES = INPUT_BOX_MARKER.encode("utf-8")
116
+
117
+ _WS_RE_STR = re.compile(r"\s+")
118
+ _WS_RE_BYTES = re.compile(rb"\s+")
119
+
120
+ # ── #896: paste-chip / box-changed detection ──────────────────────────────────
121
+ #
122
+ # Root cause of #896: ``fingerprint_in_text`` / ``fingerprint_in_bytes`` both
123
+ # check only for the *literal* first-40-chars of the briefing in the captured
124
+ # pane/bytes. Two scenarios produce false negatives even though the paste
125
+ # landed:
126
+ #
127
+ # 1. Claude Code **collapses** a large/multi-line paste into a placeholder
128
+ # chip ("❯ [Pasted text #1 +NNN lines]") — the literal briefing text never
129
+ # appears in ``capture-pane -p`` or the PTY log.
130
+ #
131
+ # 2. The input box is bounded-height and scrolls to show the **tail** of the
132
+ # pasted text (cursor at end of content), so the fingerprint — taken from
133
+ # the START — has scrolled out of the visible/captured region.
134
+ #
135
+ # ``_PASTE_CHIP_RE`` / ``_PASTE_CHIP_BYTES_RE`` detect the collapsed chip.
136
+ # ``paste_landed`` / ``paste_landed_bytes`` are the broadened predicates used
137
+ # in place of the bare ``fingerprint_in_*`` checks in ``coord.interactive``
138
+ # and ``coord.agent``. They are defined here — once — so all three call-sites
139
+ # stay in sync.
140
+ #
141
+ # LIVE-VERIFIED (#896 review follow-up): the inferred chip wording above was
142
+ # confirmed against a real render rather than left as a guess. Launched
143
+ # interactive ``claude`` v2.1.198 in a scratch tmux session, pasted a 58-line
144
+ # buffer via ``tmux load-buffer`` + ``paste-buffer -p``, and captured the
145
+ # collapsed input box with ``tmux capture-pane -p``. Literal rendered line:
146
+ #
147
+ # ❯ [Pasted text #1 +58 lines]
148
+ #
149
+ # — confirming the ``[Pasted text #N +NNN lines]`` form (with a trailing
150
+ # "paste again to expand" hint on the line below, not matched here). The
151
+ # pattern below requires both fragments to co-occur as a single chip
152
+ # (``[^\]]*`` bridges the "#1 " counter between them) rather than matching
153
+ # either fragment independently — narrower than the original OR-of-two-
154
+ # alternatives version, so unrelated "+N lines]" text elsewhere in the
155
+ # input-box region (e.g. briefing prose describing a diff) can't false-
156
+ # positive.
157
+ _PASTE_CHIP_RE = re.compile(r"\[Pasted text[^\]]*\+\s*\d+\s*lines\]")
158
+ _PASTE_CHIP_BYTES_RE = re.compile(rb"\[Pasted text[^\]]*\+\s*\d+\s*lines\]")
159
+
160
+
161
+ def briefing_fingerprint(briefing: str, length: int = 40) -> str:
162
+ """Return a whitespace-normalized snippet of *briefing* for paste verification.
163
+
164
+ Collapsing runs of whitespace to a single space and taking a short prefix
165
+ makes the check robust to the terminal re-wrapping the pasted text across
166
+ lines, while still being specific enough that it won't spuriously match
167
+ unrelated screen content (banner text, help hints, etc.).
168
+ """
169
+ return _WS_RE_STR.sub(" ", briefing).strip()[:length]
170
+
171
+
172
+ def fingerprint_in_text(text: str, fingerprint: str) -> bool:
173
+ """True when *fingerprint* (see :func:`briefing_fingerprint`) appears in *text*.
174
+
175
+ Used against tmux's ``capture-pane -p`` output, which is already
176
+ fully-rendered plain text (no ANSI escapes to worry about). An empty
177
+ fingerprint (e.g. a briefing shorter than nothing) trivially matches —
178
+ there's nothing to verify.
179
+ """
180
+ if not fingerprint:
181
+ return True
182
+ return fingerprint in _WS_RE_STR.sub(" ", text)
183
+
184
+
185
+ def fingerprint_in_bytes(raw: bytes, fingerprint: str) -> bool:
186
+ """Byte-oriented twin of :func:`fingerprint_in_text` for the PTY paths.
187
+
188
+ The PTY log/screen buffer contains raw TTY bytes — ANSI escapes
189
+ interleaved with the rendered text — so this is a best-effort heuristic
190
+ rather than the exact match ``fingerprint_in_text`` gets from tmux's
191
+ pre-rendered ``capture-pane`` output. A redraw that splits the pasted
192
+ text across escape sequences can still produce a false negative, which
193
+ just triggers a (harmless, bounded) retry.
194
+ """
195
+ if not fingerprint:
196
+ return True
197
+ normalized = _WS_RE_BYTES.sub(b" ", raw)
198
+ return fingerprint.encode("utf-8") in normalized
199
+
200
+
201
+ def paste_landed(
202
+ pane_text: str,
203
+ *,
204
+ fingerprint: str,
205
+ baseline: str | None = None,
206
+ ) -> bool:
207
+ """True when a paste of the briefing (identified by *fingerprint*) has landed.
208
+
209
+ Broadened over the bare :func:`fingerprint_in_text` check (#896) to
210
+ handle the two scenarios where ``fingerprint_in_text`` gives false
211
+ negatives even though the paste actually arrived:
212
+
213
+ * **Paste-chip**: Claude Code collapses a large/multi-line paste into a
214
+ placeholder chip (``❯ [Pasted text #1 +NNN lines]``) — the literal
215
+ briefing text never appears in ``capture-pane -p``.
216
+ * **Scrolled tail**: the input box is bounded-height and renders the
217
+ **tail** of the pasted text (cursor at end), so the fingerprint —
218
+ taken from the *start* — has scrolled out of the visible region.
219
+
220
+ The chip and box-changed checks are deliberately scoped to the
221
+ **input-box region** (characters at or after the ``❯`` INPUT_BOX_MARKER)
222
+ so that async startup banners mutating the top of the pane do NOT count
223
+ as evidence of a paste — this was the #865 trap.
224
+
225
+ Args:
226
+ pane_text: The current ``tmux capture-pane -p`` output (plain text,
227
+ no ANSI escapes).
228
+ fingerprint: The whitespace-normalised briefing snippet returned by
229
+ :func:`briefing_fingerprint`.
230
+ baseline: The pane text captured *before* the paste was sent.
231
+ When provided, a non-empty input-box region that differs from the
232
+ corresponding region in *baseline* is treated as evidence that the
233
+ paste arrived (box went from empty placeholder to holding content).
234
+
235
+ Returns:
236
+ ``True`` when any of the following holds:
237
+
238
+ * :func:`fingerprint_in_text` matches (fast path, no scoping needed).
239
+ * :data:`_PASTE_CHIP_RE` matches somewhere in the input-box region.
240
+ * The input-box region is non-empty and differs from the same region
241
+ in *baseline* (box changed after the paste).
242
+ """
243
+ if not fingerprint:
244
+ return True # nothing to verify
245
+
246
+ # Fast path: literal fingerprint visible anywhere in the pane.
247
+ if fingerprint_in_text(pane_text, fingerprint):
248
+ return True
249
+
250
+ # Locate the input-box region so the broader checks don't count async
251
+ # startup banners at the top of the pane.
252
+ marker_idx = pane_text.find(INPUT_BOX_MARKER)
253
+ if marker_idx == -1:
254
+ # No input box visible — can't safely scope the chip/changed checks,
255
+ # so fall back to "not yet landed" and let the caller retry/time out.
256
+ return False
257
+
258
+ region = pane_text[marker_idx:]
259
+
260
+ # Paste-chip: Claude Code collapses large pastes to a chip.
261
+ if _PASTE_CHIP_RE.search(region):
262
+ return True
263
+
264
+ # Box-changed from the pre-paste baseline: the input-box region is
265
+ # non-empty and differs from what it looked like before the paste.
266
+ if baseline is not None:
267
+ baseline_marker_idx = baseline.find(INPUT_BOX_MARKER)
268
+ if baseline_marker_idx != -1:
269
+ baseline_region = baseline[baseline_marker_idx:]
270
+ if region.strip() and region != baseline_region:
271
+ return True
272
+
273
+ return False
274
+
275
+
276
+ def paste_landed_bytes(raw: bytes, fingerprint: str) -> bool:
277
+ """Byte-oriented twin of :func:`paste_landed` for the PTY relay paths.
278
+
279
+ The PTY log/screen buffer contains raw TTY bytes (ANSI escapes
280
+ interleaved with rendered text), so this is more heuristic than
281
+ :func:`paste_landed`. Checks in priority order:
282
+
283
+ 1. :func:`fingerprint_in_bytes` — literal fingerprint present (fast path).
284
+ 2. :data:`_PASTE_CHIP_BYTES_RE` after :data:`INPUT_BOX_MARKER_BYTES` —
285
+ Claude Code collapsed the large paste to a chip.
286
+
287
+ The baseline / box-changed check is omitted here: the PTY log is an
288
+ append-only stream of all terminal output rather than a snapshot of the
289
+ current screen, so "the log grew" is too coarse a signal (the log always
290
+ grows as ``claude`` runs).
291
+
292
+ Args:
293
+ raw: The raw PTY log bytes (may contain ANSI escape sequences).
294
+ fingerprint: The whitespace-normalised briefing snippet returned by
295
+ :func:`briefing_fingerprint`.
296
+
297
+ Returns:
298
+ ``True`` when the fingerprint or paste-chip marker is found.
299
+ """
300
+ if not fingerprint:
301
+ return True
302
+
303
+ # Fast path
304
+ if fingerprint_in_bytes(raw, fingerprint):
305
+ return True
306
+
307
+ # Paste-chip scoped to after the input-box marker (reduces false positives
308
+ # from any "[Pasted text" that might appear in the system prompt or header).
309
+ marker_idx = raw.find(INPUT_BOX_MARKER_BYTES)
310
+ if marker_idx != -1:
311
+ region = raw[marker_idx:]
312
+ if _PASTE_CHIP_BYTES_RE.search(region):
313
+ return True
314
+
315
+ return False
316
+
317
+
318
+ class ClaudePtyProvider(Provider):
319
+ """Interactive ``claude`` (no ``-p``) attached to a PTY.
320
+
321
+ This provider is **opt-in** — operators select it by adding a definition
322
+ to ``coordinator.yml`` ``providers.definitions`` with
323
+ ``type: claude-pty``, then either:
324
+
325
+ * setting it as a per-repo ``provider:`` override, or
326
+ * setting ``providers.default: <name>`` to make it the global default.
327
+
328
+ Args:
329
+ binary: Override the worker binary name/path. ``None`` falls back to
330
+ :data:`coord.agent.DEFAULT_WORKER_BINARY` (``"claude"``).
331
+ model: Fallback model id/alias from the provider definition
332
+ (``ProviderDef.model``). Used only when neither an explicit
333
+ ``resolved_model`` nor ``spec.model`` is set — see
334
+ :meth:`build_command`.
335
+ env: Extra environment variables from the provider definition
336
+ (``ProviderDef.env``, already ``${VAR}``-expanded by config
337
+ parsing). Returned verbatim by :meth:`env`.
338
+ extra_args: Additional argv entries from the provider definition
339
+ (``ProviderDef.extra_args``). Appended to the end of the argv
340
+ built by :meth:`build_command`.
341
+ """
342
+
343
+ def __init__(
344
+ self,
345
+ binary: str | None = None,
346
+ *,
347
+ model: str | None = None,
348
+ env: dict[str, str] | None = None,
349
+ extra_args: list[str] | None = None,
350
+ ) -> None:
351
+ self._binary = binary
352
+ self._model = model
353
+ self._env = dict(env) if env else {}
354
+ self._extra_args = list(extra_args) if extra_args else []
355
+
356
+ # ── Capabilities ──────────────────────────────────────────────────────────
357
+
358
+ def capabilities(self) -> Capabilities:
359
+ """Subscription-billed interactive worker.
360
+
361
+ ``billing_mode="subscription"``: interactive ``claude`` sessions are
362
+ covered by the operator's Max/Pro plan and do not consume the
363
+ ``claude -p`` metered credit pool — this is the entire reason the
364
+ provider exists (#425).
365
+
366
+ ``enforces_deny_list=False`` is set **conservatively**: this PR does
367
+ not verify that the interactive PTY mode actually enforces
368
+ ``--allowedTools`` / ``--permission-mode`` (the agent passes those
369
+ flags as a safety hedge, but the runtime behaviour is unverified).
370
+ The safety gate in :meth:`coord.agent.AgentServer.assign` keys off
371
+ this flag and refuses write-capable assignment types on
372
+ unverified-deny-list providers. Flip to ``True`` only after the
373
+ deny-list enforcement is demonstrably exercised in the PTY path.
374
+
375
+ Other flags:
376
+ * ``resume=False`` — no ``--resume`` flag wired in the PTY argv.
377
+ * ``inject=False`` — no mid-session injection path exists for PTY.
378
+ * ``cost_reporting=False`` — interactive ``claude`` does not emit a
379
+ per-run cost figure in the TTY stream.
380
+ * ``true_system_prompt=True`` — the argv still passes
381
+ ``--system-prompt`` so the worker honours it the same way
382
+ ``claude -p`` does.
383
+ """
384
+ return Capabilities(
385
+ resume=False,
386
+ inject=False,
387
+ cost_reporting=False,
388
+ true_system_prompt=True,
389
+ enforces_deny_list=False,
390
+ billing_mode="subscription",
391
+ # #437: STRUCTURAL TOS-COMPLIANCE GATE. Interactive Claude
392
+ # Code on a Max/Pro subscription is covered ONLY for
393
+ # human-attended use — running it unsupervised would violate
394
+ # Anthropic ToS §3.7. Flagging the provider here means the
395
+ # coordinator's unattended dispatch paths (``coord plan`` /
396
+ # ``coord approve`` / auto-review / auto-reassign /
397
+ # reconciliation) refuse to select this provider at all.
398
+ # The only path that may launch this backend is
399
+ # ``coord assign --interactive``, which attaches the worker
400
+ # to the operator's local TTY and is HUMAN-CLOSED. See
401
+ # :class:`~coord.providers.base.Capabilities`.
402
+ human_attended_only=True,
403
+ )
404
+
405
+ # ── Core methods ──────────────────────────────────────────────────────────
406
+
407
+ def build_command( # noqa: PLR0912 (mirrors ClaudeProvider's spec.type branches)
408
+ self,
409
+ spec: "AssignmentSpec",
410
+ *,
411
+ resolved_model: str | None = None,
412
+ system_prompt: str | None = None,
413
+ allowed_tools: str | None = None,
414
+ permission_mode: str = "acceptEdits",
415
+ ) -> list[str]:
416
+ """Build the interactive ``claude`` argv for *spec*.
417
+
418
+ Unlike :meth:`ClaudeProvider.build_command` this:
419
+
420
+ * Does **not** pass ``-p``.
421
+ * Does **not** pass ``--input-format`` / ``--output-format`` /
422
+ ``--verbose`` (interactive ``claude`` writes TUI output).
423
+ * Still passes ``--system-prompt``, ``--allowedTools``,
424
+ ``--permission-mode``, and ``--model`` so the operator's safety
425
+ posture and the configured model are honoured even though
426
+ enforcement is unverified.
427
+ """
428
+ # Deferred import — keeps the cycle latent.
429
+ from coord.agent import ( # noqa: PLC0415
430
+ DEFAULT_WORKER_BINARY,
431
+ MILESTONE_CHAT_DENY_COMMANDS,
432
+ MILESTONE_CHAT_SYSTEM_PROMPT,
433
+ NEW_ISSUE_CHAT_DENY_COMMANDS,
434
+ NEW_ISSUE_CHAT_SYSTEM_PROMPT,
435
+ REFINEMENT_SYSTEM_PROMPT,
436
+ TEST_CHAT_SYSTEM_PROMPT,
437
+ WORKER_PLAN_PROMPT,
438
+ WORKER_SYSTEM_PROMPT,
439
+ build_deny_prompt,
440
+ )
441
+
442
+ binary = self._binary if self._binary is not None else DEFAULT_WORKER_BINARY
443
+
444
+ # Precedence: explicit resolved_model > spec.model > provider-
445
+ # definition model (ProviderDef.model, threaded in via __init__) —
446
+ # a plain ClaudePtyProvider().build_command(spec) with no definition
447
+ # model stays self-consistent (self._model is None).
448
+ if resolved_model is not None:
449
+ effective_model = resolved_model
450
+ elif spec.model is not None:
451
+ effective_model = spec.model
452
+ else:
453
+ effective_model = self._model
454
+
455
+ # Compute system_prompt / allowed_tools from spec.type when not
456
+ # provided — same logic as ClaudeProvider so the safety hedge flags
457
+ # carry the same content for the same spec.
458
+ if system_prompt is None or allowed_tools is None:
459
+ if spec.type == "plan":
460
+ _sp = spec.system_prompt if spec.system_prompt else WORKER_PLAN_PROMPT
461
+ _at = "Read,Bash"
462
+ elif spec.type == "refinement":
463
+ _sp = spec.system_prompt if spec.system_prompt else REFINEMENT_SYSTEM_PROMPT
464
+ _at = "Read"
465
+ elif spec.type == "test-chat":
466
+ _sp = spec.system_prompt if spec.system_prompt else TEST_CHAT_SYSTEM_PROMPT
467
+ _sp += build_deny_prompt(spec.deny_commands)
468
+ _at = "Read,Bash"
469
+ elif spec.type == "new-issue-chat":
470
+ _sp = spec.system_prompt if spec.system_prompt else NEW_ISSUE_CHAT_SYSTEM_PROMPT
471
+ _sp += build_deny_prompt(NEW_ISSUE_CHAT_DENY_COMMANDS)
472
+ if spec.new_issue_guidance:
473
+ _sp += (
474
+ "\n\nThe user's repo has the following guidance for "
475
+ "new-issue drafts. Follow it: ask focused questions "
476
+ "matched to the required sections, then produce a "
477
+ "finalised issue body using the same structure. Do not "
478
+ "invent sections that aren't there; do not omit required "
479
+ "sections (mark them `(TBD)` if the conversation hasn't "
480
+ "covered them yet).\n\n"
481
+ + spec.new_issue_guidance
482
+ )
483
+ _at = "Read,Bash"
484
+ elif spec.type == "milestone-chat":
485
+ _sp = spec.system_prompt if spec.system_prompt else MILESTONE_CHAT_SYSTEM_PROMPT
486
+ _sp += build_deny_prompt(MILESTONE_CHAT_DENY_COMMANDS)
487
+ _at = "Read,Bash"
488
+ else:
489
+ _sp = spec.system_prompt if spec.system_prompt else WORKER_SYSTEM_PROMPT
490
+ _sp += build_deny_prompt(spec.deny_commands)
491
+ _at = "Read,Edit,Write,Bash"
492
+
493
+ if system_prompt is None:
494
+ system_prompt = _sp
495
+ if allowed_tools is None:
496
+ allowed_tools = _at
497
+
498
+ # NOTE: no -p, no stream-json flags. --system-prompt /
499
+ # --allowedTools / --permission-mode are still passed as a safety
500
+ # hedge even though enforcement is unverified in the PTY path
501
+ # (capabilities().enforces_deny_list == False).
502
+ argv: list[str] = [
503
+ binary,
504
+ "--system-prompt", system_prompt,
505
+ "--allowedTools", allowed_tools,
506
+ "--permission-mode", permission_mode,
507
+ ]
508
+ if effective_model:
509
+ argv.extend(["--model", effective_model])
510
+ # #1706: provider-definition extra_args go last, after every flag
511
+ # this method itself constructs.
512
+ if self._extra_args:
513
+ argv.extend(self._extra_args)
514
+ return argv
515
+
516
+ def oneshot_command(
517
+ self,
518
+ *,
519
+ system_prompt: str,
520
+ output_format: str | None = "json",
521
+ ) -> list[str]:
522
+ """Best-effort one-shot argv for the PTY provider.
523
+
524
+ Interactive ``claude`` does not support the non-interactive ``-p``
525
+ mode one-shot pattern.
526
+
527
+ The coordinator's unattended oneshot paths (brain planning,
528
+ dashboard assistant) guard against human-attended-only providers
529
+ via :func:`coord.providers.resolve_default_provider`, which raises
530
+ :class:`ValueError` before ``oneshot_command()`` is reached when
531
+ ``capabilities().human_attended_only=True``. This method is
532
+ therefore a belt-and-suspenders fallback: if it is somehow invoked
533
+ despite the guard, it returns a valid ``claude -p`` style argv (the
534
+ same shape as :class:`~.claude.ClaudeProvider`) rather than raising,
535
+ so callers still get a workable command list.
536
+
537
+ Note: like :class:`~.claude.ClaudeProvider`'s ``oneshot_command``,
538
+ this does **not** thread in ``self._model`` / ``self._extra_args``
539
+ / ``self._env`` (#1706) — oneshot calls don't take an
540
+ ``AssignmentSpec`` and sit outside the assignment spawn path.
541
+
542
+ Args:
543
+ system_prompt: The system prompt for the call.
544
+ output_format: Forwarded to the argv exactly as for
545
+ :class:`~.claude.ClaudeProvider`.
546
+ """
547
+ from coord.agent import DEFAULT_WORKER_BINARY # noqa: PLC0415
548
+ binary = self._binary if self._binary is not None else DEFAULT_WORKER_BINARY
549
+ cmd = [binary, "-p", "--system-prompt", system_prompt]
550
+ if output_format is not None:
551
+ cmd.extend(["--output-format", output_format])
552
+ return cmd
553
+
554
+ def initial_input(self, spec: "AssignmentSpec") -> bytes:
555
+ """Return the briefing wrapped in a bracketed-paste block.
556
+
557
+ Interactive ``claude`` reads typed input from the TTY, NOT stream-json
558
+ envelopes. A real briefing is multi-line, and the TUI swallows
559
+ embedded newlines unless the whole block arrives as a bracketed paste
560
+ (:data:`BRACKETED_PASTE_START` ... :data:`BRACKETED_PASTE_END`). This
561
+ returns only that paste block — **no trailing newline and no submit
562
+ key**. The submitting carriage return (``\\r``; ``\\n`` does NOT
563
+ submit under the TUI's enhanced keyboard protocol) is written
564
+ *separately* by :meth:`coord.agent.AgentServer._spawn_pty` after a
565
+ short settle delay, because a CR glued onto ``ESC[201~`` in the same
566
+ write is consumed by paste-end processing. Verified live against
567
+ interactive ``claude`` (#425 smoke).
568
+ """
569
+ body = spec.briefing.rstrip("\n")
570
+ return BRACKETED_PASTE_START + body.encode("utf-8") + BRACKETED_PASTE_END
571
+
572
+ def result_marker(self) -> str:
573
+ """Sentinel written by the PTY spawn path after the worker exits.
574
+
575
+ Interactive ``claude`` does not emit a stream-json ``result`` event,
576
+ so the standard marker never appears. :mod:`coord.agent` writes
577
+ :data:`PTY_RESULT_MARKER` to the log after the subprocess exits so
578
+ downstream code that polls for completion (the reap thread's
579
+ ``_log_has_result`` helper) still has something to look for in the
580
+ PTY case.
581
+ """
582
+ return PTY_RESULT_MARKER
583
+
584
+ def env(self) -> dict[str, str]:
585
+ """Extra environment variables from the provider definition (#1706).
586
+
587
+ The agent already forces ``TERM`` via the PTY setup (see
588
+ ``AgentServer._spawn_pty``); this returns a copy of
589
+ ``ProviderDef.env`` (already ``${VAR}``-expanded by config parsing)
590
+ threaded in via ``__init__``, merged on top by the spawn path. Empty
591
+ dict when the provider was constructed with no ``env`` (matches
592
+ pre-#1706 behaviour for no-config deployments).
593
+ """
594
+ return dict(self._env)
595
+
596
+ def parse_log(
597
+ self, log_path: str | Path, tail_bytes: int = 65536
598
+ ) -> WorkerSummary:
599
+ """Best-effort parse of the worker log.
600
+
601
+ Delegates to :func:`coord.worker_events.parse_log`, which silently
602
+ skips non-JSON lines. Interactive ``claude`` writes raw TTY bytes
603
+ (ANSI escape sequences, prompts, model text) rather than
604
+ stream-json, so the returned :class:`WorkerSummary` will usually be
605
+ mostly blank — that is the correct minimal-tail-parser behaviour
606
+ for this provider until the PTY format gets its own structured
607
+ log channel.
608
+ """
609
+ from coord.worker_events import parse_log # noqa: PLC0415
610
+ return parse_log(log_path, tail_bytes=tail_bytes)