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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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)
|